血染红旗 · 1934
by EpicCoder881432 lines45.7 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>血染红旗 · 1934</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
background: #0d0a08;
color: #e8d8c0;
font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
/* ============ 导航 ============ */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 14px 28px;
background: rgba(13, 10, 8, 0.82);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(120, 60, 40, 0.22);
}
nav .logo {
font-size: 18px; font-weight: 800; letter-spacing: 0.14em;
color: #d04030;
text-shadow: 0 0 16px rgba(200, 50, 30, 0.4);
}
nav .links { display: flex; gap: 24px; }
nav .links a {
color: #a89080; text-decoration: none; font-size: 14px;
letter-spacing: 0.06em; transition: color 0.2s;
}
nav .links a:hover { color: #e8d8c0; }
/* ============ 首屏 ============ */
header {
min-height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
padding: 100px 24px 60px;
position: relative;
background:
radial-gradient(ellipse at 50% 60%, rgba(140, 25, 18, 0.32), transparent 62%),
radial-gradient(ellipse at 50% 100%, rgba(90, 50, 20, 0.2), transparent 55%),
#0d0a08;
}
header::after {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(90, 45, 30, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(90, 45, 30, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
pointer-events: none;
}
header .tag {
font-size: 13px; letter-spacing: 0.5em; color: #8a6050;
margin-bottom: 22px; position: relative; z-index: 1;
}
header h1 {
font-size: clamp(48px, 11vw, 110px);
font-weight: 900;
letter-spacing: 0.1em;
color: #d83020;
text-shadow:
0 0 40px rgba(200, 40, 25, 0.5),
0 0 100px rgba(180, 30, 20, 0.3);
line-height: 1.05;
position: relative; z-index: 1;
}
header .sub {
margin-top: 24px;
font-size: clamp(14px, 2.2vw, 19px);
letter-spacing: 0.28em;
color: #a89080;
position: relative; z-index: 1;
}
header .divider {
width: 60px; height: 2px;
background: linear-gradient(90deg, transparent, #c84030, transparent);
margin: 34px auto;
position: relative; z-index: 1;
}
header .desc {
max-width: 540px;
font-size: 15px; line-height: 2;
color: #8a7868;
position: relative; z-index: 1;
}
.scroll-hint {
position: absolute; bottom: 34px; left: 50%; transform: translateX(-50%);
font-size: 12px; letter-spacing: 0.3em; color: #6a5040;
animation: bounce 2.4s infinite;
z-index: 1;
}
@keyframes bounce {
0%, 100% { transform: translateX(-50%) translateY(0); opacity: 0.5; }
50% { transform: translateX(-50%) translateY(8px); opacity: 1; }
}
/* ============ 通用 section ============ */
section {
padding: 100px 24px;
max-width: 1060px;
margin: 0 auto;
}
.sec-title {
font-size: 13px; letter-spacing: 0.42em; color: #a05040;
margin-bottom: 14px;
}
.sec-head {
font-size: clamp(28px, 4vw, 42px);
font-weight: 800; letter-spacing: 0.06em;
color: #e8d8c0;
margin-bottom: 40px;
}
.sec-head span { color: #c84030; }
/* ============ 背景故事 ============ */
#story .prose {
max-width: 720px;
font-size: 16px; line-height: 2.1;
color: #b8a090;
}
#story .prose p { margin-bottom: 22px; }
#story .prose em {
color: #d88060; font-style: normal;
}
#story .prose .quote {
border-left: 3px solid #a03020;
padding-left: 22px;
margin: 34px 0;
font-size: 17px; color: #c8b0a0;
line-height: 1.9;
}
/* ============ 武器图鉴 ============ */
.weapon-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 18px;
}
.weapon-card {
background: linear-gradient(160deg, rgba(50, 28, 20, 0.5), rgba(25, 16, 12, 0.7));
border: 1px solid rgba(120, 60, 40, 0.28);
border-radius: 10px;
padding: 22px;
transition: all 0.25s;
position: relative;
overflow: hidden;
}
.weapon-card::before {
content: '';
position: absolute; top: 0; left: 0; width: 3px; height: 100%;
background: linear-gradient(180deg, #c84030, transparent);
opacity: 0.6;
}
.weapon-card:hover {
border-color: rgba(200, 80, 50, 0.55);
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(120, 20, 15, 0.3);
}
.weapon-card h3 {
font-size: 20px; color: #e8d8c0; margin-bottom: 4px;
letter-spacing: 0.04em;
}
.weapon-card .en {
font-size: 11px; letter-spacing: 0.2em; color: #7a6050;
margin-bottom: 16px;
}
.weapon-card .stats {
display: flex; flex-direction: column; gap: 8px;
}
.stat-row {
display: flex; justify-content: space-between;
font-size: 13px; color: #a89080;
padding: 4px 0;
border-bottom: 1px solid rgba(120, 60, 40, 0.15);
}
.stat-row b { color: #d8c0a8; font-weight: 600; }
.weapon-card .note {
margin-top: 14px;
font-size: 12px; line-height: 1.7; color: #8a7060;
}
/* ============ 角色展示 ============ */
#character .char-area {
display: flex; flex-wrap: wrap; gap: 40px;
align-items: center; justify-content: center;
}
#character canvas {
image-rendering: pixelated;
border-radius: 10px;
box-shadow: 0 0 60px rgba(180, 30, 30, 0.2), 0 0 0 1px rgba(120, 60, 40, 0.3);
background: rgba(20, 14, 10, 0.6);
}
#character .char-info { max-width: 380px; }
#character .char-info p {
font-size: 14px; line-height: 2; color: #a89080;
margin-bottom: 16px;
}
#character .char-info .tagline {
color: #d88060; font-size: 15px; margin-bottom: 20px;
}
/* ============ 游戏区 ============ */
#game {
max-width: 100%;
padding: 60px 0 100px;
}
#game .game-wrap {
max-width: 960px;
margin: 0 auto;
padding: 0 24px;
position: relative;
}
#gameCanvas {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
box-shadow: 0 0 90px rgba(180, 30, 30, 0.28), 0 0 0 1px rgba(120, 60, 40, 0.3);
touch-action: none;
cursor: crosshair;
}
#gameOverlay {
position: absolute;
top: 0; left: 24px; right: 24px;
aspect-ratio: 960 / 540;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
background: rgba(10, 7, 5, 0.78);
backdrop-filter: blur(6px);
border-radius: 12px;
transition: opacity 0.35s;
padding: 20px;
}
#gameOverlay.hidden { opacity: 0; pointer-events: none; }
#gameOverlay h3 {
font-size: clamp(24px, 4vw, 40px);
color: #d83020; letter-spacing: 0.12em;
text-shadow: 0 0 30px rgba(200, 40, 25, 0.5);
margin-bottom: 12px;
}
#gameOverlay p {
font-size: 14px; color: #a89080;
line-height: 1.9; max-width: 440px;
margin-bottom: 8px;
}
#gameOverlay .controls-hint {
font-size: 12px; color: #6a5040; margin: 14px 0 22px;
letter-spacing: 0.06em;
}
#gameOverlay button {
padding: 14px 42px;
font-size: 16px; font-weight: 700;
letter-spacing: 0.16em;
color: #fff;
background: linear-gradient(135deg, #b82818, #8a1810);
border: 1px solid #d04030;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
box-shadow: 0 4px 24px rgba(180, 30, 20, 0.4);
}
#gameOverlay button:hover {
background: linear-gradient(135deg, #d83822, #a82018);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(200, 40, 25, 0.55);
}
/* ============ 页脚 ============ */
footer {
padding: 50px 24px;
text-align: center;
border-top: 1px solid rgba(120, 60, 40, 0.2);
font-size: 12px;
color: #6a5040;
letter-spacing: 0.1em;
line-height: 2;
}
footer .red { color: #a03020; }
/* ============ 响应式 ============ */
@media (max-width: 640px) {
nav { padding: 12px 16px; }
nav .links { gap: 14px; }
nav .links a { font-size: 12px; }
section { padding: 70px 18px; }
#character canvas { width: 260px; }
}
</style>
</head>
<body>
<!-- ============ 导航 ============ -->
<nav>
<div class="logo">血染红旗</div>
<div class="links">
<a href="#story">背景</a>
<a href="#weapons">武器</a>
<a href="#character">角色</a>
<a href="#game">试玩</a>
</div>
</nav>
<!-- ============ 首屏 ============ -->
<header>
<div class="tag">1934 · 湘桂边界</div>
<h1>血染红旗</h1>
<div class="sub">湘江之后 · 火种不灭</div>
<div class="divider"></div>
<p class="desc">
中央红军正处在被迫转移的路上。但他们不仅仅是在逃避国民党的追杀。<br>
同时,国民党的这次清剿行动,也不仅仅是为了所谓的“赤匪”。
</p>
<div class="scroll-hint">向下滚动</div>
</header>
<!-- ============ 背景故事 ============ -->
<section id="story">
<div class="sec-title">BACKGROUND</div>
<h2 class="sec-head">那东西,<span>从民间爆发</span></h2>
<div class="prose">
<div class="quote">
“1934。中央红军正处在被迫转移的路上。但他们不仅仅是在逃避国民党的追杀。同时国民党的这次清剿行动也不其仅仅是为了所谓的赤匪。”
</div>
<p>
湘江一役,江水尽赤。残部退入湘桂边界的深山,以为甩开了追兵,却发现山里的村子早已空了。
</p>
<p>
不是逃了,是<em>变了</em>。死者从血泊里站起来,动作僵硬,眼神空洞,见到活人就扑。当地老乡管它们叫<em>走尸</em>,红军战士叫它们<em>活尸</em>,国民党的电报里则写着<em>疫民</em>。
</p>
<p>
没人知道这东西从哪来。只知道它从民间先爆发,在红军到来之前,这片山区就已经在腐烂。国民党清剿的不只是红军,可能也是在封锁、清理、掩盖这片区域正在蔓延的东西。
</p>
<p>
而你,是一名红军战士。你奉命守住一面军旗,等大部队回来。旗在,阵地在。
</p>
</div>
</section>
<!-- ============ 武器图鉴 ============ -->
<section id="weapons">
<div class="sec-title">ARSENAL</div>
<h2 class="sec-head">万国牌<span>·</span>弹药匮乏</h2>
<div class="weapon-grid">
<div class="weapon-card">
<h3>汉阳造</h3>
<div class="en">Hanyang 88 · 八八式</div>
<div class="stats">
<div class="stat-row"><span>弹容量</span><b>5 发漏夹</b></div>
<div class="stat-row"><span>射速</span><b>10-15 发/分</b></div>
<div class="stat-row"><span>故障率</span><b>高</b></div>
<div class="stat-row"><span>装填</span><b>打空才能换</b></div>
</div>
<div class="note">红军装备数量最大的步枪。结实耐用,但复装弹多,容易卡壳。打空弹夹后有空夹“漏出”的硬直。</div>
</div>
<div class="weapon-card">
<h3>三八式</h3>
<div class="en">Type 38 · 三八大盖</div>
<div class="stats">
<div class="stat-row"><span>弹容量</span><b>5 发桥夹</b></div>
<div class="stat-row"><span>射速</span><b>10-15 发/分</b></div>
<div class="stat-row"><span>故障率</span><b>低</b></div>
<div class="stat-row"><span>装填</span><b>可随时补弹</b></div>
</div>
<div class="note">主要靠缴获,数量稀少。防尘盖设计使其在沙尘环境下可靠性极高,适合精准射击。</div>
</div>
<div class="weapon-card">
<h3>驳壳枪</h3>
<div class="en">Mauser C96 · 盒子炮</div>
<div class="stats">
<div class="stat-row"><span>弹容量</span><b>10 / 20 发</b></div>
<div class="stat-row"><span>射速</span><b>半自动 / 全自动</b></div>
<div class="stat-row"><span>连发射速</span><b>900 发/分</b></div>
<div class="stat-row"><span>故障率</span><b>高</b></div>
</div>
<div class="note">只有干部和突击队才配得上。连发时后坐力极大,子弹散布严重,是清空弹匣的豪赌。</div>
</div>
<div class="weapon-card">
<h3>花机关</h3>
<div class="en">MP18 · 伯格曼</div>
<div class="stats">
<div class="stat-row"><span>弹容量</span><b>20 / 32 发</b></div>
<div class="stat-row"><span>射速</span><b>400-500 发/分</b></div>
<div class="stat-row"><span>故障率</span><b>较低</b></div>
<div class="stat-row"><span>定位</span><b>突击队火力</b></div>
</div>
<div class="note">近距离压制尸潮的利器,但弹药消耗极快。飞夺泸定桥时,突击队就是带着花机关冲上去的。</div>
</div>
<div class="weapon-card">
<h3>马克沁</h3>
<div class="en">Maxim · 重机枪</div>
<div class="stats">
<div class="stat-row"><span>弹容量</span><b>333 发弹带</b></div>
<div class="stat-row"><span>射速</span><b>600 发/分</b></div>
<div class="stat-row"><span>故障率</span><b>中</b></div>
<div class="stat-row"><span>定位</span><b>固定防御</b></div>
</div>
<div class="note">全枪重达 62kg,水冷枪管。阵地防守的核心,但需要副射手协助供弹和加水,移动性极差。</div>
</div>
<div class="weapon-card">
<h3>马尾手榴弹</h3>
<div class="en">Horse-tail Grenade</div>
<div class="stats">
<div class="stat-row"><span>弹体</span><b>约 93mm</b></div>
<div class="stat-row"><span>投掷</span><b>麻绳旋转甩出</b></div>
<div class="stat-row"><span>故障率</span><b>高</b></div>
<div class="stat-row"><span>风险</span><b>哑弹 / 提前爆</b></div>
</div>
<div class="note">红军兵工厂自制,弹体后拖着麻绳,旋转甩出能飞得更远。引信易受潮,是高风险高回报的清场手段。</div>
</div>
</div>
</section>
<!-- ============ 角色展示 ============ -->
<section id="character">
<div class="sec-title">THE SOLDIER</div>
<h2 class="sec-head">一般<span>红军战士</span></h2>
<div class="char-area">
<canvas id="charCanvas" width="320" height="480"></canvas>
<div class="char-info">
<div class="tagline">八角帽 · 红五星 · 灰布军装 · 绑腿 · 草鞋</div>
<p>
1934 年前后中央红军战士的标准形象:灰色中山装式军服,八角帽中央缝一颗布质红五角星,领口缀红布领章,打绑腿,穿草鞋。
</p>
<p>
长征途中物资匮乏,颜色和布料已不尽统一。战士身形消瘦,衣衫破旧,但帽子上的红星始终醒目。
</p>
<p>
这个模型采用低多边形方块风格,适合 2D 俯视角或侧视角游戏。五个关键视觉符号——八角帽、红五星、灰军装、绑腿、草鞋——确保一眼就能认出是红军。
</p>
</div>
</div>
</section>
<!-- ============ 游戏区 ============ -->
<section id="game">
<div class="sec-title" style="text-align:center; max-width:960px; margin:0 auto 14px;">PLAYABLE PROTOTYPE</div>
<h2 class="sec-head" style="text-align:center; max-width:960px; margin:0 auto 40px;">守住<span>红旗</span></h2>
<div class="game-wrap">
<canvas id="gameCanvas" width="960" height="540"></canvas>
<div id="gameOverlay">
<h3>血染红旗</h3>
<p>守住军旗,撑过 5 波走尸。旗在,阵地在。</p>
<div class="controls-hint">
WASD / 方向键 移动 · 鼠标 瞄准 · 左键 开火 · R 装填
</div>
<button id="startBtn">开始战斗</button>
</div>
</div>
</section>
<!-- ============ 页脚 ============ -->
<footer>
血染红旗 · 1934<br>
<span class="red">旗在,阵地在。</span><br>
一个 G&B-like 合作生存游戏的原型 · 用 HTML 构建
</footer>
<script>
(() => {
'use strict';
/* ================================================================
角色展示画布
================================================================ */
(function drawCharacter() {
const c = document.getElementById('charCanvas');
const g = c.getContext('2d');
const S = 10, W = 32, H = 48;
g.imageSmoothingEnabled = false;
g.scale(S, S);
const C = {
hat: '#7a8299', coat: '#6b7280', pants: '#5a626f',
wrap: '#8b8578', shoe: '#8b6f47', skin: '#e8b88a',
star: '#d02020', collar: '#d02020'
};
const px = (x, y, w, h, col) => { g.fillStyle = col; g.fillRect(x, y, w, h); };
const shade = (hex, amt) => {
const n = hex.replace('#', '');
let r = parseInt(n.substr(0, 2), 16), gg = parseInt(n.substr(2, 2), 16), b = parseInt(n.substr(4, 2), 16);
r = Math.max(0, Math.min(255, Math.round(r + amt * 255)));
gg = Math.max(0, Math.min(255, Math.round(gg + amt * 255)));
b = Math.max(0, Math.min(255, Math.round(b + amt * 255)));
return '#' + [r, gg, b].map(v => v.toString(16).padStart(2, '0')).join('');
};
// 腿
px(10, 28, 5, 8, C.pants);
px(17, 28, 5, 8, C.pants);
for (let i = 0; i < 5; i++) {
const col = i % 2 === 0 ? C.wrap : shade(C.wrap, -0.16);
px(10, 36 + i, 5, 1, col);
px(17, 36 + i, 5, 1, col);
}
px(9, 41, 7, 3, C.shoe);
px(16, 41, 7, 3, C.shoe);
px(9, 42, 7, 1, shade(C.shoe, -0.12));
px(16, 42, 7, 1, shade(C.shoe, -0.12));
// 躯干
px(9, 18, 14, 13, C.coat);
px(11, 18, 3, 3, C.collar);
px(18, 18, 3, 3, C.collar);
px(11, 23, 4, 3, shade(C.coat, -0.1));
px(17, 23, 4, 3, shade(C.coat, -0.1));
px(11, 23, 4, 1, shade(C.coat, -0.2));
px(17, 23, 4, 1, shade(C.coat, -0.2));
px(9, 28, 14, 2, '#3a2418');
px(15, 28, 2, 2, '#8a6a40');
// 手臂
px(5, 19, 4, 9, C.coat);
px(5, 28, 4, 3, C.skin);
px(23, 19, 4, 9, C.coat);
px(23, 28, 4, 3, C.skin);
px(5, 19, 1, 9, shade(C.coat, -0.12));
px(26, 19, 1, 9, shade(C.coat, -0.12));
// 头
px(10, 10, 12, 8, C.skin);
px(12, 13, 2, 2, '#2a1a10');
px(18, 13, 2, 2, '#2a1a10');
px(15, 16, 2, 1, '#a06848');
px(9, 13, 1, 3, shade(C.skin, -0.08));
px(22, 13, 1, 3, shade(C.skin, -0.08));
// 军帽
px(9, 2, 14, 6, C.hat);
px(9, 2, 14, 1, shade(C.hat, 0.1));
px(7, 8, 18, 2, shade(C.hat, -0.22));
px(7, 9, 18, 1, shade(C.hat, -0.35));
px(14, 3, 4, 4, C.star);
px(15, 4, 2, 2, shade(C.star, 0.25));
// 步枪
px(25, 13, 2, 19, '#2a2018');
px(25, 13, 1, 19, '#4a3828');
px(24, 32, 4, 6, '#5a3a20');
px(24, 32, 4, 1, '#7a5030');
px(24, 22, 3, 2, '#3a2a1a');
})();
/* ================================================================
游戏主逻辑
================================================================ */
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const W = 960, H = 540;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
const overlay = document.getElementById('gameOverlay');
const startBtn = document.getElementById('startBtn');
// ---- 音效 ----
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
function tone(freq, dur, type, vol, slideTo) {
if (!audioCtx) return;
type = type || 'square'; vol = vol || 0.05;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(vol, t + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + dur + 0.03);
}
function noiseBurst(dur, vol) {
if (!audioCtx) return;
const size = audioCtx.sampleRate * dur;
const buf = audioCtx.createBuffer(1, size, audioCtx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < size; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / size, 2);
const src = audioCtx.createBufferSource();
src.buffer = buf;
const gn = audioCtx.createGain();
gn.gain.value = vol;
src.connect(gn);
gn.connect(audioCtx.destination);
src.start();
}
// ---- 常量 ----
const FLAG = { x: 480, y: 260, r: 26, hp: 100, maxHp: 100 };
const PLAYER_SPEED = 2.6;
const MAG_SIZE = 5;
const RELOAD_TIME = 48;
const FIRE_COOLDOWN = 30;
const BULLET_SPEED = 12;
const BULLET_DAMAGE = 60;
const obstacles = [
{ x: 150, y: 110, w: 60, h: 60 },
{ x: 750, y: 110, w: 60, h: 60 },
{ x: 150, y: 370, w: 60, h: 60 },
{ x: 750, y: 370, w: 60, h: 60 },
{ x: 420, y: 70, w: 120, h: 36 },
{ x: 420, y: 434, w: 120, h: 36 },
{ x: 70, y: 230, w: 36, h: 80 },
{ x: 854, y: 230, w: 36, h: 80 },
];
// ---- 状态 ----
let player, zombies, bullets, particles, bloodStains;
let wave, enemiesToSpawn, spawnTimer, state, frame, shake;
let kills, score, message, messageTimer;
const keys = {};
const mouse = { x: W / 2, y: H / 2, down: false };
// ---- 工具 ----
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function dist(ax, ay, bx, by) {
const dx = ax - bx, dy = ay - by;
return Math.sqrt(dx * dx + dy * dy);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function burst(x, y, color, n, power, size) {
power = power || 4; size = size || 2;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 0.6 + Math.random() * power;
particles.push({
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 0.8,
life: 1,
decay: 0.018 + Math.random() * 0.025,
color,
size: size * (0.6 + Math.random() * 0.8)
});
}
}
// ---- 初始化 ----
function resetPlayer() {
player = {
x: FLAG.x, y: FLAG.y + 80, w: 18, h: 24,
vx: 0, vy: 0,
hp: 100, maxHp: 100,
ammo: MAG_SIZE, reloading: false, reloadTimer: 0,
fireCooldown: 0, facing: -Math.PI / 2,
hurtTimer: 0, down: false, downTimer: 0
};
}
function startWave(n) {
wave = n;
enemiesToSpawn = 3 + n * 2;
spawnTimer = 0;
message = n === 1 ? '第一波 · 守住军旗' : '第 ' + n + ' 波';
messageTimer = 100;
}
function init() {
zombies = []; bullets = []; particles = []; bloodStains = [];
frame = 0; shake = 0; kills = 0; score = 0;
state = 'playing';
message = '湘江之后 · 血染红旗';
messageTimer = 160;
resetPlayer();
FLAG.hp = FLAG.maxHp;
startWave(1);
}
// ---- 僵尸生成 ----
function spawnZombie() {
const side = Math.floor(Math.random() * 4);
const m = 40;
let x, y;
if (side === 0) { x = Math.random() * W; y = -m; }
else if (side === 1) { x = W + m; y = Math.random() * H; }
else if (side === 2) { x = Math.random() * W; y = H + m; }
else { x = -m; y = Math.random() * H; }
let type = 0;
const r = Math.random();
if (wave >= 3 && r < 0.2) type = 2;
else if (wave >= 2 && r < 0.45) type = 1;
zombies.push({
x, y, w: 18, h: 22, vx: 0, vy: 0, type,
hp: type === 2 ? 160 : 60,
maxHp: type === 2 ? 160 : 60,
speed: type === 1 ? 1.5 : type === 2 ? 0.65 : 0.95,
damage: type === 2 ? 22 : 12,
attackTimer: 0, hitTimer: 0,
wanderAngle: Math.random() * Math.PI * 2,
wanderTimer: 0
});
enemiesToSpawn--;
}
// ---- 射击 ----
function tryFire() {
if (player.down || player.reloading || player.fireCooldown > 0) return;
if (player.ammo <= 0) { startReload(); return; }
player.ammo--;
player.fireCooldown = FIRE_COOLDOWN;
const a = player.facing;
const sx = player.x + Math.cos(a) * 18;
const sy = player.y + Math.sin(a) * 18;
bullets.push({
x: sx, y: sy,
vx: Math.cos(a) * BULLET_SPEED,
vy: Math.sin(a) * BULLET_SPEED,
life: 50
});
burst(sx, sy, '#ffcc66', 4, 2.5, 1.6);
tone(180, 0.08, 'sawtooth', 0.08, 60);
noiseBurst(0.06, 0.04);
shake = Math.max(shake, 5);
if (player.ammo <= 0) startReload();
}
function startReload() {
if (player.reloading || player.ammo === MAG_SIZE) return;
player.reloading = true;
player.reloadTimer = RELOAD_TIME;
tone(320, 0.06, 'square', 0.03);
}
function finishReload() {
player.reloading = false;
player.ammo = MAG_SIZE;
tone(480, 0.08, 'square', 0.04, 640);
}
// ---- 伤害 ----
function damagePlayer(amount) {
if (player.down) return;
player.hp -= amount;
player.hurtTimer = 12;
shake = Math.max(shake, 6);
burst(player.x, player.y, '#ff3b3b', 8, 3, 2);
tone(160, 0.12, 'sawtooth', 0.06, 60);
if (player.hp <= 0) {
player.hp = 0;
player.down = true;
player.downTimer = 180;
burst(player.x, player.y, '#8a1010', 18, 4, 3);
}
}
function damageZombie(z, amount) {
z.hp -= amount;
z.hitTimer = 8;
burst(z.x, z.y, '#8a1010', 5, 2.5, 2);
if (z.hp <= 0) {
kills++;
score += z.type === 2 ? 30 : z.type === 1 ? 15 : 10;
burst(z.x, z.y, '#6a0808', 14, 4, 2.5);
bloodStains.push({ x: z.x, y: z.y, r: 12 + Math.random() * 10, a: 0.5 });
tone(90, 0.14, 'sawtooth', 0.05, 40);
const i = zombies.indexOf(z);
if (i >= 0) zombies.splice(i, 1);
}
}
// ---- 更新 ----
function update() {
frame++;
if (shake > 0.4) shake *= 0.86; else shake = 0;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 0.12; p.vx *= 0.97;
p.x += p.vx; p.y += p.vy;
p.life -= p.decay;
if (p.life <= 0) particles.splice(i, 1);
}
for (let i = bloodStains.length - 1; i >= 0; i--) {
bloodStains[i].a -= 0.0006;
if (bloodStains[i].a <= 0) bloodStains.splice(i, 1);
}
if (messageTimer > 0) messageTimer--;
if (state !== 'playing') return;
if (player.down) {
player.downTimer--;
if (player.downTimer <= 0) {
state = 'lost';
message = '军旗未倒 · 战士已逝';
messageTimer = 99999;
}
return;
}
// 移动
const left = keys['ArrowLeft'] || keys['KeyA'];
const right = keys['ArrowRight'] || keys['KeyD'];
const up = keys['ArrowUp'] || keys['KeyW'];
const down = keys['ArrowDown'] || keys['KeyS'];
let mx = 0, my = 0;
if (left) mx -= 1;
if (right) mx += 1;
if (up) my -= 1;
if (down) my += 1;
if (mx !== 0 && my !== 0) { mx *= 0.7071; my *= 0.7071; }
player.vx = mx * PLAYER_SPEED;
player.vy = my * PLAYER_SPEED;
player.x += player.vx;
for (const o of obstacles) if (rectsOverlap(player, o)) {
if (player.vx > 0) player.x = o.x - player.w;
else if (player.vx < 0) player.x = o.x + o.w;
}
player.x = clamp(player.x, player.w / 2, W - player.w / 2);
player.y += player.vy;
for (const o of obstacles) if (rectsOverlap(player, o)) {
if (player.vy > 0) player.y = o.y - player.h;
else if (player.vy < 0) player.y = o.y + o.h;
}
player.y = clamp(player.y, player.h / 2, H - player.h / 2);
// 瞄准
player.facing = Math.atan2(mouse.y - player.y, mouse.x - player.x);
if (player.fireCooldown > 0) player.fireCooldown--;
if (player.hurtTimer > 0) player.hurtTimer--;
if (player.reloading) {
player.reloadTimer--;
if (player.reloadTimer <= 0) finishReload();
}
if (mouse.down) tryFire();
// 子弹
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
let hit = false;
for (const o of obstacles) {
if (b.x > o.x && b.x < o.x + o.w && b.y > o.y && b.y < o.y + o.h) { hit = true; break; }
}
if (!hit) {
for (const z of zombies) {
if (dist(b.x, b.y, z.x, z.y) < 16) { damageZombie(z, BULLET_DAMAGE); hit = true; break; }
}
}
if (hit || b.life <= 0 || b.x < 0 || b.x > W || b.y < 0 || b.y > H) {
if (hit) burst(b.x, b.y, '#ffaa55', 3, 2, 1.4);
bullets.splice(i, 1);
}
}
// 僵尸 AI
for (const z of zombies) {
if (z.hitTimer > 0) z.hitTimer--;
let tx = FLAG.x, ty = FLAG.y, targetIsPlayer = false;
if (!player.down) {
const dP = dist(z.x, z.y, player.x, player.y);
const dF = dist(z.x, z.y, FLAG.x, FLAG.y);
if (dP < 180 || dP < dF) { tx = player.x; ty = player.y; targetIsPlayer = true; }
}
const dx = tx - z.x, dy = ty - z.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
z.wanderTimer--;
if (z.wanderTimer <= 0) {
z.wanderTimer = 30 + Math.random() * 40;
z.wanderAngle = Math.random() * Math.PI * 2;
}
const wx = Math.cos(z.wanderAngle) * 0.3;
const wy = Math.sin(z.wanderAngle) * 0.3;
z.vx = (dx / d) * z.speed + wx;
z.vy = (dy / d) * z.speed + wy;
z.x += z.vx;
for (const o of obstacles) if (rectsOverlap(z, o)) {
if (z.vx > 0) z.x = o.x - z.w; else if (z.vx < 0) z.x = o.x + o.w;
}
z.y += z.vy;
for (const o of obstacles) if (rectsOverlap(z, o)) {
if (z.vy > 0) z.y = o.y - z.h; else if (z.vy < 0) z.y = o.y + o.h;
}
z.attackTimer--;
if (targetIsPlayer && dist(z.x, z.y, player.x, player.y) < 22) {
if (z.attackTimer <= 0) { z.attackTimer = 36; damagePlayer(z.damage); }
} else if (!targetIsPlayer && dist(z.x, z.y, FLAG.x, FLAG.y) < 30) {
if (z.attackTimer <= 0) {
z.attackTimer = 50;
FLAG.hp -= z.damage * 0.6;
burst(FLAG.x, FLAG.y, '#cc2222', 6, 2.5, 1.8);
tone(100, 0.1, 'sawtooth', 0.04, 50);
if (FLAG.hp <= 0) {
FLAG.hp = 0;
state = 'lost';
message = '军旗倒下 · 阵地失守';
messageTimer = 99999;
burst(FLAG.x, FLAG.y, '#ff2a2a', 30, 6, 3);
}
}
}
}
// 波次
if (enemiesToSpawn > 0) {
spawnTimer--;
if (spawnTimer <= 0) {
spawnZombie();
spawnTimer = Math.max(14, 40 - wave * 2);
}
} else if (zombies.length === 0) {
if (wave < 5) {
startWave(wave + 1);
player.hp = Math.min(player.maxHp, player.hp + 25);
player.ammo = MAG_SIZE;
player.reloading = false;
} else {
state = 'won';
message = '血染红旗 · 阵地仍在';
messageTimer = 99999;
score += player.hp * 5 + FLAG.hp * 3;
}
}
// 旗边回血
if (!player.down && dist(player.x, player.y, FLAG.x, FLAG.y) < FLAG.r + 30) {
player.hp = Math.min(player.maxHp, player.hp + 0.04);
}
}
// ---- 绘制 ----
function drawBackground() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0d0808');
g.addColorStop(0.5, '#1a0d0a');
g.addColorStop(1, '#120806');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
const wi = Math.min(1, wave / 5);
const g2 = ctx.createRadialGradient(W / 2, H / 2, 60, W / 2, H / 2, 620);
g2.addColorStop(0, 'rgba(120, 20, 15, ' + (0.12 + wi * 0.15) + ')');
g2.addColorStop(1, 'rgba(120, 20, 15, 0)');
ctx.fillStyle = g2;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(60, 30, 20, 0.25)';
for (let i = 0; i < 80; i++) {
ctx.fillRect((i * 137.5) % W, (i * 89.7) % H, 2, 2);
}
}
function drawBloodStains() {
for (const s of bloodStains) {
ctx.globalAlpha = s.a;
ctx.fillStyle = '#5a0808';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawObstacles() {
for (const o of obstacles) {
ctx.fillStyle = '#2a1a14';
roundRect(o.x, o.y, o.w, o.h, 5);
ctx.fill();
ctx.fillStyle = '#3d261c';
roundRect(o.x + 3, o.y + 3, o.w - 6, 4, 2);
ctx.fill();
ctx.strokeStyle = 'rgba(80, 40, 30, 0.5)';
ctx.lineWidth = 1;
roundRect(o.x, o.y, o.w, o.h, 5);
ctx.stroke();
}
}
function drawFlag() {
const { x, y } = FLAG;
const ratio = FLAG.hp / FLAG.maxHp;
ctx.strokeStyle = '#6b4a2a';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(x, y + 30);
ctx.lineTo(x, y - 55);
ctx.stroke();
const t = frame * 0.06;
const w1 = Math.sin(t) * 4;
const w2 = Math.sin(t + 1.2) * 3;
ctx.save();
ctx.globalAlpha = 0.9;
const fg = ctx.createLinearGradient(x, y - 55, x + 48, y - 5);
fg.addColorStop(0, ratio > 0.4 ? '#c41e1e' : '#6a0e0e');
fg.addColorStop(1, ratio > 0.4 ? '#8a1010' : '#3a0606');
ctx.fillStyle = fg;
ctx.beginPath();
ctx.moveTo(x, y - 55);
ctx.quadraticCurveTo(x + 24, y - 58 + w1, x + 48, y - 50 + w2);
ctx.lineTo(x + 48, y - 12 + w2);
ctx.quadraticCurveTo(x + 24, y - 10 + w1, x, y - 5);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffd93d';
ctx.save();
ctx.translate(x + 22, y - 32 + w1 * 0.5);
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? 9 : 4;
const a = -Math.PI / 2 + i * Math.PI / 5;
const px = Math.cos(a) * r, py = Math.sin(a) * r;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.restore();
const barW = 70, barH = 6;
const bx = x - barW / 2, by = y + 38;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(bx, by, barW, barH, 3);
ctx.fill();
ctx.fillStyle = ratio > 0.5 ? '#e03030' : ratio > 0.25 ? '#d08020' : '#a01010';
roundRect(bx, by, barW * ratio, barH, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(200, 80, 60, 0.6)';
ctx.lineWidth = 1;
roundRect(bx, by, barW, barH, 3);
ctx.stroke();
}
function drawPlayer() {
if (player.down) {
ctx.save();
ctx.globalAlpha = 0.7;
ctx.fillStyle = '#5a3030';
roundRect(player.x - 14, player.y - 8, 28, 16, 6);
ctx.fill();
ctx.restore();
const r = player.downTimer / 180;
ctx.strokeStyle = 'rgba(255, ' + (100 + 100 * (1 - r)) + ', 50, 0.9)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, 22, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * r);
ctx.stroke();
return;
}
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(player.x, player.y + 14, 12, 5, 0, 0, Math.PI * 2);
ctx.fill();
const hurt = player.hurtTimer > 0 && Math.floor(player.hurtTimer / 3) % 2 === 0;
ctx.save();
if (hurt) ctx.globalAlpha = 0.6;
ctx.fillStyle = '#5a6a4a';
roundRect(player.x - player.w / 2, player.y - player.h / 2, player.w, player.h, 5);
ctx.fill();
ctx.fillStyle = '#3d4a30';
roundRect(player.x - 10, player.y - 16, 20, 8, 3);
ctx.fill();
ctx.fillStyle = '#d02020';
ctx.beginPath();
ctx.arc(player.x, player.y - 12, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.facing);
ctx.fillStyle = '#3a2818';
ctx.fillRect(6, -2, 22, 4);
ctx.fillStyle = '#6a4a28';
ctx.fillRect(6, -1.5, 8, 3);
ctx.restore();
ctx.restore();
ctx.save();
ctx.globalAlpha = 0.25;
ctx.strokeStyle = '#ffcc66';
ctx.lineWidth = 1;
ctx.setLineDash([6, 8]);
ctx.beginPath();
ctx.moveTo(player.x, player.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
ctx.restore();
const hpR = player.hp / player.maxHp;
const bw = 36, bh = 4;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(player.x - bw / 2, player.y - 28, bw, bh, 2);
ctx.fill();
ctx.fillStyle = hpR > 0.5 ? '#50c050' : hpR > 0.25 ? '#d0a020' : '#d03030';
roundRect(player.x - bw / 2, player.y - 28, bw * hpR, bh, 2);
ctx.fill();
if (player.reloading) {
const r = 1 - player.reloadTimer / RELOAD_TIME;
ctx.strokeStyle = '#ffcc66';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, 24, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * r);
ctx.stroke();
}
}
function drawZombies() {
for (const z of zombies) {
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(z.x, z.y + 12, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
const hit = z.hitTimer > 0;
ctx.save();
if (hit) ctx.globalAlpha = 0.7;
let bc = '#4a5a3a', hc = '#5a6a4a';
if (z.type === 1) { bc = '#6a3a2a'; hc = '#7a4a3a'; }
if (z.type === 2) { bc = '#3a3a3a'; hc = '#4a4a4a'; }
ctx.fillStyle = bc;
roundRect(z.x - z.w / 2, z.y - z.h / 2, z.w, z.h, 4);
ctx.fill();
ctx.fillStyle = hc;
ctx.beginPath();
ctx.arc(z.x, z.y - z.h / 2 - 2, 7, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff2020';
ctx.beginPath();
ctx.arc(z.x - 2.5, z.y - z.h / 2 - 3, 1.8, 0, Math.PI * 2);
ctx.arc(z.x + 2.5, z.y - z.h / 2 - 3, 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(100, 10, 10, 0.6)';
ctx.beginPath();
ctx.arc(z.x + 3, z.y + 3, 3, 0, Math.PI * 2);
ctx.fill();
if (z.type === 2) {
ctx.strokeStyle = '#888';
ctx.lineWidth = 1.5;
roundRect(z.x - z.w / 2 - 1, z.y - z.h / 2 - 1, z.w + 2, z.h + 2, 5);
ctx.stroke();
}
ctx.restore();
if (z.hp < z.maxHp) {
const hr = z.hp / z.maxHp;
const bw = 26, bh = 3;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(z.x - bw / 2, z.y - z.h / 2 - 14, bw, bh);
ctx.fillStyle = z.type === 2 ? '#a06060' : '#d03030';
ctx.fillRect(z.x - bw / 2, z.y - z.h / 2 - 14, bw * hr, bh);
}
}
}
function drawBullets() {
for (const b of bullets) {
ctx.save();
ctx.shadowColor = '#ffcc66';
ctx.shadowBlur = 8;
ctx.fillStyle = '#ffe080';
ctx.beginPath();
ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(255, 200, 100, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - b.vx * 1.5, b.y - b.vy * 1.5);
ctx.stroke();
}
}
function drawParticles() {
for (const p of particles) {
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawHUD() {
ctx.save();
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffd0b0';
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('第 ' + wave + ' 波', 24, 32);
const ammoStr = player.reloading ? '装填中…' : player.ammo + ' / ' + MAG_SIZE;
ctx.fillStyle = player.ammo === 0 && !player.reloading ? '#ff5050' : '#e8d8c0';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText(ammoStr, 24, 62);
if (player.ammo < MAG_SIZE && !player.reloading) {
ctx.fillStyle = 'rgba(255, 200, 100, 0.6)';
ctx.font = '14px system-ui, sans-serif';
ctx.fillText('按 R 装填', 24, 86);
}
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(230, 210, 190, 0.85)';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('击杀 ' + kills, W - 24, 32);
ctx.fillStyle = 'rgba(230, 210, 190, 0.5)';
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('得分 ' + score, W - 24, 58);
const hr = player.hp / player.maxHp;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(W - 200, H - 40, 176, 16, 8);
ctx.fill();
ctx.fillStyle = hr > 0.5 ? '#50c050' : hr > 0.25 ? '#d0a020' : '#d03030';
roundRect(W - 200, H - 40, 176 * hr, 16, 8);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('生命 ' + Math.ceil(player.hp) + ' / ' + player.maxHp, W - 112, H - 32);
ctx.restore();
if (messageTimer > 0 && message) {
ctx.save();
const a = Math.min(1, messageTimer / 40) * 0.9;
ctx.globalAlpha = a;
ctx.textAlign = 'center';
ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillStyle = '#ffd0b0';
ctx.shadowColor = '#8a1010';
ctx.shadowBlur = 16;
ctx.fillText(message, W / 2, H / 2 - 80);
ctx.restore();
}
if (frame < 240 && state === 'playing') {
ctx.save();
ctx.globalAlpha = Math.max(0, 1 - frame / 240) * 0.55;
ctx.textAlign = 'center';
ctx.fillStyle = '#d0b8a0';
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('WASD / 方向键移动 鼠标瞄准 左键开火 R 装填', W / 2, H - 24);
ctx.restore();
}
}
function drawEndScreen() {
if (state !== 'won' && state !== 'lost') return;
ctx.save();
ctx.fillStyle = state === 'won' ? 'rgba(10, 15, 8, 0.8)' : 'rgba(15, 5, 5, 0.82)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
if (state === 'won') {
ctx.shadowColor = '#ffd93d';
ctx.shadowBlur = 26;
ctx.fillStyle = '#ffd93d';
ctx.font = 'bold 56px system-ui, sans-serif';
ctx.fillText('阵地仍在', W / 2, H / 2 - 70);
ctx.shadowBlur = 0;
ctx.fillStyle = '#e8d8c0';
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText('击杀 ' + kills + ' 得分 ' + score, W / 2, H / 2 + 5);
ctx.fillStyle = 'rgba(230, 210, 190, 0.7)';
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('大部队已经归来', W / 2, H / 2 + 55);
} else {
ctx.shadowColor = '#8a1010';
ctx.shadowBlur = 26;
ctx.fillStyle = '#d02020';
ctx.font = 'bold 56px system-ui, sans-serif';
ctx.fillText('红旗不倒', W / 2, H / 2 - 70);
ctx.shadowBlur = 0;
ctx.fillStyle = '#e8d8c0';
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText('坚守至第 ' + wave + ' 波 击杀 ' + kills, W / 2, H / 2 + 5);
ctx.fillStyle = 'rgba(230, 210, 190, 0.7)';
ctx.font = '18px system-ui, sans-serif';
ctx.fillText('但火种已经留下', W / 2, H / 2 + 55);
}
const a = 0.5 + Math.sin(frame * 0.08) * 0.4;
ctx.fillStyle = 'rgba(200, 180, 160, ' + a + ')';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('按 R 重新开始', W / 2, H / 2 + 120);
ctx.restore();
}
function render() {
ctx.save();
ctx.clearRect(0, 0, W, H);
if (shake > 0.4) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
}
drawBackground();
drawBloodStains();
drawObstacles();
drawFlag();
drawZombies();
drawBullets();
drawPlayer();
drawParticles();
drawHUD();
drawEndScreen();
ctx.restore();
}
// ---- 主循环 ----
const STEP = 1000 / 60;
let lastTime = performance.now();
let acc = 0;
let gameRunning = false;
function loop(now) {
requestAnimationFrame(loop);
if (!gameRunning) {
// 未开始时也画一帧背景
drawBackground();
return;
}
let delta = now - lastTime;
lastTime = now;
if (delta > 200) delta = 200;
acc += delta;
let guard = 0;
while (acc >= STEP && guard++ < 5) {
update();
acc -= STEP;
}
render();
}
// ---- 输入 ----
window.addEventListener('keydown', e => {
initAudio();
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
if (!e.repeat) {
if (e.code === 'KeyR') {
if (state === 'won' || state === 'lost') {
init();
overlay.classList.add('hidden');
gameRunning = true;
lastTime = performance.now();
} else {
startReload();
}
}
}
keys[e.code] = true;
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
mouse.x = (e.clientX - r.left) * (W / r.width);
mouse.y = (e.clientY - r.top) * (H / r.height);
});
canvas.addEventListener('mousedown', e => {
initAudio();
if (e.button === 0) mouse.down = true;
});
window.addEventListener('mouseup', () => { mouse.down = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
// 触屏
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
canvas.addEventListener('touchstart', e => {
initAudio();
const t = e.touches[0];
const r = canvas.getBoundingClientRect();
mouse.x = (t.clientX - r.left) * (W / r.width);
mouse.y = (t.clientY - r.top) * (H / r.height);
mouse.down = true;
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchmove', e => {
const t = e.touches[0];
const r = canvas.getBoundingClientRect();
mouse.x = (t.clientX - r.left) * (W / r.width);
mouse.y = (t.clientY - r.top) * (H / r.height);
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchend', e => {
mouse.down = false;
e.preventDefault();
}, { passive: false });
}
// ---- 开始按钮 ----
startBtn.addEventListener('click', () => {
initAudio();
init();
overlay.classList.add('hidden');
gameRunning = true;
lastTime = performance.now();
});
// 启动
init();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Game Source: 血染红旗 · 1934
Creator: EpicCoder88
Libraries: none
Complexity: complex (1432 lines, 45.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: 1934-epiccoder88" to link back to the original. Then publish at arcadelab.ai/publish.