🎮ArcadeLab

血染红旗 · 1934

by EpicCoder88
1432 lines45.7 KB
▶ Play
<!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 / 方向键 移动 &nbsp;·&nbsp; 鼠标 瞄准 &nbsp;·&nbsp; 左键 开火 &nbsp;·&nbsp; 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.