ゾンビタイピングのソースコード全文 — JavaScript・HTML・CSS

▶ ゾンビタイピングで遊ぶ 📖 作り方を1から学ぶ

このページでは、ひなテックGamesで実際に動いている「ゾンビタイピング」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直したコードではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。

このゲームの見どころは3つ。「し」を si でも shi でも打てるローマ字判定エンジン、画面にいる複数のゾンビから最初の1打で狙いを決めるロックオン、そして Canvas を使わずにCSS の transform だけで「奥から迫ってくる」自分視点を作っている投影の仕組みです。タイピングゲームがアクションゲームに化ける瞬間を、コードで追いかけてみてください。

コードはコピーして自分のパソコンで自由に動かせます。ことばのリスト(words.js)や敵の種類・ボスの仕組みを差し替えれば、自分だけのタイピングアクションも作れますよ。

ゾンビタイピングは3つのファイルでできている

ゾンビタイピングは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

ファイル役割
index.htmlゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する
style.css見た目のデザイン。色・大きさ・レイアウト・アニメーション
game.jsゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など

この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。

index.html — ゲーム画面の骨組み

index.htmlは、ゲームの「部品」をHTMLで並べるファイルです。中心になるのは舞台のzombie-stageで、その中に背景のSVGstage-bg、ゾンビが歩くzombie-field、照準reticle、被弾の爪あとclaw、ステージ内のHUD(ウェーブ・HP・撃破数・コンボ)、手元に大きくお題を出すtarget-board、ウェーブの始まりやクリアを知らせるwave-banner、そしてスタート画面start-overlayが重ねて入っています。

背景のstage-bgは中身がからっぽです。墓場・夜の校庭・商店街…と、ウェーブごとにJavaScriptが絵を組み立てて入れかえるからです。スタート画面のpractice-toggleにチェックを入れると、敵がゆっくり来る「ゆっくり練習」になります(ランキングには載りません)。

ゾンビ本体のHTMLはここには書かれていません。ゲーム中にJavaScriptがcreateElementで作ってzombie-fieldに追加します。「最初から置いておく部品」と「あとから作る部品」の使い分けは、敵がたくさん出るゲームの基本パターンです。結果発表用のresult-overlayには、称号・スコア・S〜Dの項目別ランクを出す場所が用意してあります。

※ 下のコードでは、ゲームの動きに関係のないタグ(広告・アクセス解析・検索エンジン向けの記述)を省いています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ゾンビタイピング|無料ブラウザゲーム|ひなテックGames</title>
  <meta name="description" content="自分視点で正面から迫るゾンビと戦う、ローマ字タイピングのサバイバルゲーム!1打で1発、最初の1打でロックオン、打ち切って撃破。3択クイズや長文のボスと戦い、投げ物、脱力系のお題も。si/shiなど複数の打ち方に対応した無料ブラウザゲーム。">
  <link rel="stylesheet" href="../../css/style.css?v=20260927">
  <link rel="stylesheet" href="style.css?v=20260819b">
  <link rel="stylesheet" href="../../css/leaderboard.css">
</head>
<body>
  <div id="header"></div>

  <div class="game-page">
    <h1 class="game-page-title">ゾンビタイピング</h1>
    <div class="game-page-tags">
      <a href="../../tags/typing/" class="tag">タイピング</a>
      <a href="../../tags/survival/" class="tag">サバイバル</a>
    </div>
    <!-- タイピング練習コースの「あそぶ」段に置いた導線。基本から学びたい人を練習エンジンへ送る -->
    <div class="course-banner">
      <p>⌨️ このゲームは「<a href="../../typing/" data-course-link="hub">タイピング練習コース</a>」の<strong>あそぶ</strong>ページです。キーを見ないで打てるようになりたいときは <a href="../typing-lesson/" data-course-link="lesson">はじめてのタイピング練習</a> へ(1キーずつ・時間制限なし)。</p>
      <p>くわしく: <a href="../../typing/home-position/" data-course-link="home-position">ホームポジションの覚え方</a>・<a href="../../typing/posture/" data-course-link="posture">正しい姿勢と指の形</a></p>
    </div>

    <div class="game-container">
      <!-- スコアエリア(HP・ウェーブ・コンボはステージ内のHUDに出す) -->
      <div class="score-area">
        <div class="score-box">
          <span class="score-label">スコア</span>
          <span class="score-value" id="score">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-score">0</span>
        </div>
      </div>

      <!-- ゾンビステージ(自分視点。奥の道からゾンビがこちらへ迫ってくる) -->
      <div class="zombie-stage idle" id="zombie-stage">
        <!-- 背景(夜空・地平線・道・遠景)。中身は game.js の SCENES からウェーブごとに描き替える -->
        <svg class="stage-bg" id="stage-bg" viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice" aria-hidden="true"></svg>

        <div class="zombie-field" id="zombie-field"></div>

        <!-- 照準・発砲・被弾の演出 -->
        <div class="reticle" id="reticle" aria-hidden="true"></div>
        <div class="muzzle" id="muzzle" aria-hidden="true"></div>
        <svg class="claw" id="claw" viewBox="0 0 640 360" preserveAspectRatio="none" aria-hidden="true">
          <path d="M120 40 L300 330" /><path d="M200 20 L380 330" /><path d="M290 30 L470 320" />
        </svg>
        <div class="vignette" aria-hidden="true"></div>

        <!-- ステージ内HUD -->
        <div class="stage-hud">
          <div class="hud-item"><span class="hud-label">WAVE</span><span class="hud-value" id="hud-wave">1</span></div>
          <div class="hud-item hud-hp"><span class="hud-label">HP</span><span class="hud-value" id="hud-hp"></span></div>
          <div class="hud-item"><span class="hud-label">撃破</span><span class="hud-value" id="hud-kills">0</span></div>
          <div class="hud-item"><span class="hud-label">コンボ</span><span class="hud-value" id="hud-combo">0</span></div>
        </div>

        <!-- 手元の大きなお題表示 -->
        <div class="target-board">
          <div class="target-jp" id="target-jp"></div>
          <div class="target-romaji" id="target-romaji"></div>
        </div>

        <div class="wave-banner" id="wave-banner"></div>
        <div class="countdown" id="countdown"></div>
        <div class="wave-tip" id="wave-tip"></div>
        <div class="ime-hint" id="ime-hint">日本語入力(IME)がONのようです。そのままでも打てますが、「半角/英数」キーを押すと確実です</div>

        <!-- スタート画面(ステージ内) -->
        <div class="start-overlay active" id="start-overlay">
          <div class="start-inner">
            <div class="start-title">ゾンビタイピング</div>
            <p class="start-lead">奥の道からせまるゾンビを、<strong>ローマ字を打って</strong>撃て!</p>
            <ol class="start-steps">
              <li><span class="step-icon">⌨️</span><span>ゾンビの頭上のことばを<br>ローマ字で打つ</span></li>
              <li><span class="step-icon">🎯</span><span>最初の1打で<br>ねらうゾンビが決まる</span></li>
              <li><span class="step-icon">💥</span><span>打ち切ると撃破!<br>目の前まで来られるとHPが減る</span></li>
            </ol>
            <button class="btn-primary btn-start" id="btn-start" type="button">▶ スタート</button>
            <div class="start-sub">Enterキーでも始められます</div>
            <label class="practice-label"><input type="checkbox" id="practice-toggle"> ゆっくり練習(ランキングに載りません)</label>
          </div>
        </div>

        <input class="mobile-input" id="mobile-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-hidden="true" tabindex="-1">
      </div>

      <!-- アクション -->
      <div class="game-actions">
        <button class="btn-mute" id="btn-mute" type="button" aria-label="サウンド切り替え">🔊</button>
      </div>

      <!-- 結果オーバーレイ -->
      <div class="result-overlay" id="result-overlay">
        <div class="result-content">
          <h2>夜に飲まれた…</h2>
          <div class="result-title-badge" id="result-title">見習いハンター</div>
          <div class="result-score-row">
            <span class="result-score-label">スコア</span>
            <span class="result-score-value" id="result-score">0</span>
          </div>
          <div class="result-bonus" id="result-bonus"></div>
          <div class="result-practice" id="result-practice" style="display:none;">練習モードのためランキングには登録されません</div>
          <div class="result-grades-wrap">
            <div class="result-grades" id="result-grades"></div>
            <div class="result-overall-wrap"><span class="result-overall-label">総合</span><span class="result-overall" id="result-overall">C</span></div>
          </div>
          <div class="result-stats">
            <div class="result-stat">
              <span class="result-stat-label">撃破数</span>
              <span class="result-stat-value" id="result-kills">0体</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">到達ウェーブ</span>
              <span class="result-stat-value" id="result-wave">WAVE 1</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">生存時間</span>
              <span class="result-stat-value" id="result-time">0秒</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">最大コンボ</span>
              <span class="result-stat-value" id="result-combo">0</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">正確さ</span>
              <span class="result-stat-value" id="result-accuracy">100%</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">打鍵速度</span>
              <span class="result-stat-value" id="result-speed">0 打/秒</span>
            </div>
            <div class="result-stat">
              <span class="result-stat-label">PERFECT</span>
              <span class="result-stat-value" id="result-perfect">0回</span>
            </div>
          </div>
          <div class="result-best" id="result-best" style="display:none;">
            ベストスコア更新!
          </div>
          <button class="btn-primary" id="btn-restart">もう一度挑む</button>
        </div>
      </div>
    </div>

    <!-- ゲーム枠直下の広告(hinata-game-below) -->

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>「スタート」を押すと夜が始まり、奥の道から<strong>ゾンビが正面に迫って</strong>きます(自分視点)</li>
        <li>ゾンビの頭上のことばを<strong>ローマ字でタイプ</strong>。最初の1打で狙うゾンビが決まり(<strong>ロックオン</strong>)、<strong>1打で1発</strong>撃ちます。最後まで打ち切ると撃破!</li>
        <li>同じキーで始まるゾンビが複数いるときは、<strong>いちばん手前のゾンビ</strong>を自動で狙います。ミスしても打っていたことばは消えません(コンボだけ途切れます)。<strong>Esc</strong>を押すとロックオンを外してねらい直せます(打った分の進みは残ります)</li>
        <li><strong>「し」は si / shi、「じゃ」は ja / zya / jya など、どの打ち方でもOK</strong>。「っ」は子音重ね、「ん」は n / nn 両対応。日本語入力(IME)がONのままでも打てます</li>
        <li>ゾンビに目の前まで来られると<strong>HPが1つ減ります</strong>。HPが0になるとゲームオーバー。HPが減っていると<strong>回復アイテム</strong>(救急箱など)が流れてくることがあります</li>
        <li>ゲームは<strong>ウェーブ制(討伐制)</strong>。各ウェーブのノルマ(ボス戦をのぞいて1回目6体・2回目8体と2体ずつふえ、最大18体)を倒し切ると「クリア」→ 前進して次のウェーブへ。進むほどゾンビが速く・多くなります。走るゾンビは短いことば、ウェーブ4から出る<strong>でかゾンビ</strong>は長文。ウェーブ5からは道ばたから突然出る<strong>待ち伏せ</strong>、ウェーブ7からは<strong>1文字の投げ物</strong>、ウェーブ10からは動かず骨を投げてくる<strong>物陰の投げ手</strong>も現れます</li>
        <li>ウェーブ3・6・9…は<strong>ボス戦</strong>(ボスだけが出て、倒すまで他の敵は来ません)。<strong>長文3連発</strong>(1本打ち切るごとにロックオンが外れます)・<strong>3択クイズ</strong>(正解を打つと大ダメージ)・<strong>正確さ</strong>(ミスすると即ダメージ)・<strong>すばやさ</strong>(3秒でことばが変わる)の4種類が順番に出ます。ボスに目の前まで来られてもHPは1つだけ減り、ボスは下がって仕切り直しになります</li>
        <li>スコアは「打った文字数 × 敵の種類 × コンボ倍率 × 速さ」。速く(FAST!/PERFECT!)・ミスなく(精密)・目の前で(ギリギリ!)倒すほど伸び、最後に<strong>正確さボーナス</strong>が加わります</li>
        <li>結果画面では速さ・正確さ・生存・コンボの<strong>S〜Dランク</strong>と<strong>称号</strong>がもらえます。目指せ「夜明けをつれてくる者」!</li>
        <li>「ゆっくり練習」にチェックを入れると敵が遅くなります(ランキング登録なし)。キーボード付きのPC・タブレットでのプレイがおすすめです</li>
      </ul>
    </div>

    <div class="game-instructions class-note">
      <h3>🏫 授業で使うなら</h3>
      <dl class="class-note-grid">
        <dt>配るURL</dt><dd><code>https://hinata-ya.tech/games/typing/</code>(<a href="../../typing/">タイピング練習コース</a>。子どもは「つづきから」で自分の続きのステージに入れます。このゲームはコースのあとの腕だめしに)。ゲームだけ配るときは <code>https://hinata-ya.tech/games/games/zombie-typing/</code></dd>
        <dt>クラスの順位表</dt><dd><a href="../../for-teachers/class-ranking/?game=zombie-typing">教室ランキングを作る</a>と、コードを配ったクラスの記録だけで順位表が出せます。電子黒板に映せて、全国ランキングには載りません</dd>
      </dl>
      <p class="class-note-copy"><button type="button" class="btn-copy" data-copy-id="zombie-typing-course" data-copy-text="タイピング練習コースを「つづきから」1ステージ やろう。おわったら ゾンビタイピングで うでだめし しよう。\nhttps://hinata-ya.tech/games/typing/\nうでだめし:https://hinata-ya.tech/games/games/zombie-typing/">📋 課題文をコピー(練習コース)</button><button type="button" class="btn-copy" data-copy-id="zombie-typing" data-copy-text="ゾンビタイピングに ちょうせんしよう。ひらがなの 言葉を 打って、ちかづいてくる ゾンビを たおしてね。\nhttps://hinata-ya.tech/games/games/zombie-typing/">📋 ゲームだけ配る</button><a class="btn-copy" href="../../for-teachers/pack/tournament/">🎒 クラス大会パック(台本・賞状)</a></p>
      <p class="class-note-more"><a href="../../for-teachers/">先生向けガイド</a>に安全性の説明があります。スコアは評価には使わないでください。</p>
    </div>

    <div class="game-instructions">
      <h3>ゾンビタイピング よくある質問</h3>
      <ul>
        <li><strong>Q. ゾンビタイピングは無料で遊べますか?</strong><br>A. はい、完全無料です。料金や課金は一切ありません。</li>
        <li><strong>Q. ダウンロードやインストールは必要ですか?</strong><br>A. 不要です。ブラウザを開くだけですぐに遊べます。</li>
        <li><strong>Q. スマホでも遊べますか?</strong><br>A. 画面をタップすると隠れた入力欄が起動し、スマホのソフトウェアキーボードで入力できます。ただし快適に速く打つには、物理キーボード付きのPC・タブレットでのプレイがおすすめです。</li>
        <li><strong>Q. ウェーブやボスはどんな仕組みですか?ローマ字入力は難しいですか?</strong><br>A. ウェーブごとのノルマ(ボス戦をのぞいて1回目6体・2回目8体と2体ずつふえ、最大18体)を倒し切るとクリアで次のウェーブへ進み、ゾンビが速く・多くなります。走るゾンビは短いことば、でかゾンビは長文で、ウェーブ3・6・9…はボス戦。「長文」「3択クイズ」「正確さ」「すばやさ」の4種類のボスが順番に登場します。「し」はsi/shi、「じゃ」はja/zya/jyaなど複数の打ち方に対応しているので、ローマ字入力の練習にもなります。</li>
        <li><strong>Q. スコアはランキングに登録できますか?</strong><br>A. はい。プレイ後にニックネームを入力してランキングに登録でき、ベストスコアは自動で保存されます。</li>
        <li><strong>Q. ゾンビと戦うバトル要素はありますか?</strong><br>A. はい。目の前に迫るゾンビをタイピングで倒しながら進む、戦闘型のタイピングゲームです。ウェーブが進むと強いゾンビや、長文・クイズ・正確さ・すばやさの4種類のボスとも戦うことになります。</li>
      </ul>
    </div>
    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        ゾンビタイピングは、ローマ字の判定エンジンと<br>
        奥から迫る敵を擬似3Dで動かす仕組みで作られています。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/online/" class="btn-primary" data-class-link="game-promo">オンラインで習ってみる(全国OK)→</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
      </div>
    </div>

    <div class="other-games">
      <a href="../../">&larr; 他のゲームも遊ぶ</a>
    </div>
  </div>

  <div id="footer"></div>

  <script src="../../js/common.js?v=20260914" data-base="../../"></script>
  <script src="../../js/teach-tools.js?v=20260927" defer></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="../../js/game-audio.js?v=20260821b"></script>
  <script src="words.js?v=20260821d"></script>
  <script src="game.js?v=20260828"></script>
</body>
</html>

style.css — 奥行きと演出はCSSで作る

style.cssは、夜の墓場の雰囲気と「動き」を作るファイルです。ゾンビは3層構造になっていて、いちばん外の.zombieにJavaScriptが位置と大きさ(transform)を毎フレーム書き込み、内側の.z-bodyがCSSアニメーションで「よろよろ歩き」、さらに内側の絵.z-artが打たれたときの「ひるみ」を担当します。3つに分けているのは、CSSアニメーションとJavaScriptの transform が同じ要素で衝突すると位置が飛んでしまうからです。

ゾンビの絵は、画像のとき(img.z-art)も手描きSVGのとき(svg.z-art)も同じz-artクラスを付けているので、ひるみや光らせる指定は1つ書くだけで両方に効きます。画像は絵によって横幅がばらばらでも背丈がそろうように、.z-bodyをdisplay: flexにして高さで合わせ、足元を中央に置いています。

奥にいるゾンビはdata-depth属性(5段階)で暗く・くすんで見せています。毎フレーム色を変えるのではなく段階が変わったときだけクラスを付け替えるのは、負荷を抑える工夫です。ロックオンした敵はtargetedで本体が黄色く縁取られ、吹き出しも赤くなります。目の前で振りかぶっているcharging(赤く光る。このあいだに倒せばノーダメージ)、撃破のdead(崩れて消える)、到達のattacking(画面いっぱいに迫る)、被弾のclaw(爪あとが走る)も全部CSSの@keyframesです。

style.css
/* ============================================
   ゾンビタイピング - 固有スタイル(自分視点・擬似3D)
   ============================================ */

/* ステージ幅は標準(600px)より少し広げつつ、縦長寄りの比率にして左右の余白を残す
   (2026-08-19: 横に広げるとGoogleのサイドレール広告がゲーム画面近くまで来る余地ができるため、
   幅は控えめにして縦方向の迫力で見せる方針に変更) */
.game-page {
  max-width: 640px;
}

/* スコアエリア(スコア・ベストだけ。HP等はステージ内HUD) */
.score-area {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}

.score-box {
  background: #4A3B5C;
  border-radius: 8px;
  padding: 0.35rem 0.75rem;
  text-align: center;
  min-width: 60px;
  flex: 1;
}

.score-label {
  display: block;
  font-size: 0.65rem;
  color: #CBB8E8;
  letter-spacing: 0.05em;
}

.score-value {
  display: block;
  font-size: 1.1rem;
  font-weight: 700;
  color: #fff;
}

/* ============================================
   ステージ(夜の墓場の道。奥から敵が来る)
   ============================================ */
.zombie-stage {
  position: relative;
  /* 縦長寄り(4:5)。横幅を欲張らないぶん自分視点の奥行きが強調される。
     1366×768 等の背の低い画面では下の @media で高さを上書きする */
  aspect-ratio: 4 / 5;
  background: #12091F;
  border-radius: 12px;
  margin-bottom: 0.75rem;
  overflow: hidden;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
  user-select: none;
  /* 中の z-index(スタート画面 230 など)が結果オーバーレイ(200)より前に出ないよう、ここで閉じ込める */
  isolation: isolate;
}

.stage-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* 場所が変わったときの切り替え(ウェーブクリアの前進中に入れ替わる) */
.stage-bg.scene-in {
  animation: scene-in 0.9s ease;
}

@keyframes scene-in {
  0% { opacity: 0; filter: brightness(2.2); }
  40% { opacity: 1; }
  100% { opacity: 1; filter: brightness(1); }
}

.zombie-field {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.zombie-stage.shake {
  animation: stage-shake 0.18s ease;
}

@keyframes stage-shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-5px); }
  75% { transform: translateX(5px); }
}

/* 被弾時の赤フラッシュ */
.zombie-stage.hit::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(231, 76, 60, 0.35);
  pointer-events: none;
  animation: hit-flash 0.4s ease forwards;
  z-index: 185;
}

@keyframes hit-flash {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

/* ============================================
   ゾンビ(投影 transform は .zombie、歩きは .z-body、ひるみは .z-art(手描きの svg でも差し替え画像の img でも同じクラス)。
   別々の要素に分けて CSS アニメと投影の transform が衝突しないようにしている)
   ============================================ */
.zombie {
  position: absolute;
  left: 0;
  top: 0;
  /* 高さは game.js の BODY_H と必ず合わせる(吹き出し・照準の位置計算に使っている)。
     幅は手描きSVGの viewBox 比(0.8)に合わせて 152 = 190 × 0.8 */
  width: 152px;
  height: 190px;
  transform-origin: 50% 100%;
  will-change: transform;
  pointer-events: none;
}

.z-body {
  position: relative;
  width: 100%;
  height: 100%;
  transform-origin: 50% 100%;
  animation: z-walk 0.9s ease-in-out infinite;
  /* 画像スプライトは「高さ」でそろえる。腕を大きく広げた絵でも背丈が変わらないようにするため、
     幅で収める object-fit ではなく flex で足元中央に置く(transform は ひるみ演出が使うので空けておく) */
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.z-body .z-art {
  display: block;
  transform-origin: 50% 100%;
}

.z-body svg.z-art {
  width: 100%;
  height: 100%;
  flex: 0 0 auto;
}

.z-body img.z-art {
  width: auto;
  height: 100%;
  max-width: none;
  flex: 0 0 auto;
  pointer-events: none;
}

.z-shadow {
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 110px;
  height: 18px;
  transform: translateX(-50%);
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0) 70%);
}

/* 自分が歩いている感じ: 路面のしるしと道ばたの小物が奥から流れてくる。前進中は速い */
.prop {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
  will-change: transform;
}

.prop-mark {
  width: 120px;
  height: 6px;
  border-radius: 3px;
  background: #8E8598;
}

/* 小物は種類ごとに描いた viewBox が違うので、必ずここで実寸を与える。
   漏れると SVG が既定サイズ(約300px)で描かれ、地平線を基準に置いているぶん
   空まで巨大に突き出て「浮いて見える」(2026-08-16 鉄棒=鳥居に見えた件の根本原因) */
.prop-grave, .prop-cross, .prop-bar, .prop-bench,
.prop-lamp, .prop-sign, .prop-post,
.prop-stump, .prop-rock, .prop-pillar, .prop-drum {
  width: 64px;
  height: 64px;
}

.prop svg { display: block; width: 100%; height: 100%; }
.prop-tree { width: 84px; height: 84px; }

/* カメラの揺れ(歩き)。前進中は速く大きく */
.zombie-field {
  animation: cam-bob 1.3s ease-in-out infinite;
}

.zombie-stage.idle .zombie-field { animation: none; }
.zombie-stage.dashing .zombie-field { animation-duration: 0.45s; }

@keyframes cam-bob {
  0%, 100% { transform: translateY(0) rotate(0); }
  25% { transform: translateY(-2px) rotate(0.25deg); }
  75% { transform: translateY(-2px) rotate(-0.25deg); }
}

/* 奥行き(5段階): 奥ほど暗く・くすむ。クラス切替のみで毎フレームは触らない */
.zombie[data-depth="0"] { filter: brightness(0.5) saturate(0.6); }
.zombie[data-depth="1"] { filter: brightness(0.62) saturate(0.7); }
.zombie[data-depth="2"] { filter: brightness(0.76) saturate(0.82); }
.zombie[data-depth="3"] { filter: brightness(0.9) saturate(0.92); }
.zombie[data-depth="4"] { filter: none; }

@keyframes z-walk {
  0%, 100% { transform: translateY(0) rotate(-3deg); }
  50% { transform: translateY(-4%) rotate(3deg); }
}

.zombie-runner .z-body { animation-duration: 0.42s; }
.zombie-tank .z-body { animation-duration: 1.4s; }
.zombie-boss .z-body { animation-duration: 1.6s; }
.zombie-swift .z-body { animation: z-flutter 0.35s ease-in-out infinite; }
.zombie-throw .z-body { animation: z-spin 0.5s linear infinite; }
.zombie-item .z-body { animation: z-bob 1.2s ease-in-out infinite; }

@keyframes z-flutter {
  0%, 100% { transform: translateY(0) rotate(-6deg); }
  50% { transform: translateY(-8%) rotate(6deg); }
}

@keyframes z-spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

@keyframes z-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10%); }
}

/* ロックオン中の個体は黄色く縁取る */
.zombie.targeted .z-body .z-art {
  filter: drop-shadow(0 0 5px #F1C40F) drop-shadow(0 0 2px #F9E79F);
}

/* 打鍵ごとのひるみ(後ろにのけぞって白く光る) */
.z-body .z-art.flinch {
  animation: z-flinch 0.16s ease;
}

@keyframes z-flinch {
  0% { transform: translateY(0) scale(1); filter: brightness(1); }
  35% { transform: translateY(-5%) scale(1.05); filter: brightness(2.2); }
  100% { transform: translateY(0) scale(1); filter: brightness(1); }
}

/* 待ち伏せ・投げ手の登場(地面からがばっと出る)/去るとき */
.zombie.rise .z-body {
  animation: z-rise 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), z-walk 0.9s ease-in-out 0.4s infinite;
}

@keyframes z-rise {
  0% { transform: translateY(70%) scaleY(0.6); opacity: 0; }
  60% { opacity: 1; }
  100% { transform: translateY(0) scaleY(1); }
}

.zombie-lurker .z-body { animation: z-lurk 1.6s ease-in-out infinite; }
.zombie-lurker.rise .z-body { animation: z-rise 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.2), z-lurk 1.6s ease-in-out 0.4s infinite; }

@keyframes z-lurk {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50% { transform: translateY(-2%) rotate(2deg); }
}

.zombie.sink .z-body {
  animation: z-sink 0.5s ease-in forwards;
}

@keyframes z-sink {
  0% { transform: translateY(0); opacity: 1; }
  100% { transform: translateY(80%); opacity: 0; }
}

/* 目の前で振りかぶっている状態(まだ殴られていない。ここで倒せばノーダメージ) */
.zombie.charging .z-body {
  animation: z-charge 0.5s ease-in-out infinite;
}

@keyframes z-charge {
  0%, 100% { transform: translateY(0) scale(1); }
  50% { transform: translateY(-6%) scale(1.06); }
}

.zombie.charging .z-art {
  filter: drop-shadow(0 0 6px rgba(231, 76, 60, 0.9));
}

.z-bubble.charging {
  border-color: #E74C3C !important;
  animation: bubble-charge 0.4s ease-in-out infinite alternate;
}

@keyframes bubble-charge {
  0% { box-shadow: 0 0 6px rgba(231, 76, 60, 0.5); }
  100% { box-shadow: 0 0 18px rgba(231, 76, 60, 0.95); }
}

/* ウェーブ開始時などの短い説明 */
.wave-tip {
  position: absolute;
  left: 50%;
  bottom: 24%;
  transform: translateX(-50%) translateY(8px);
  max-width: 88%;
  background: rgba(10, 5, 20, 0.92);
  border: 2px solid rgba(241, 196, 15, 0.8);
  border-radius: 10px;
  padding: 8px 14px;
  color: #fff;
  font-size: 0.85rem;
  line-height: 1.5;
  text-align: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease, transform 0.3s ease;
  z-index: 215;
}

.wave-tip b { color: #F1C40F; }

.wave-tip.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ノックバック(1発で倒れないボスに当てたとき。押し戻される手応え) */
.zombie.knock .z-body {
  animation: z-knock 0.28s ease-out;
}

@keyframes z-knock {
  0% { transform: translateY(0) rotate(0); }
  30% { transform: translateY(-7%) rotate(-4deg); }
  60% { transform: translateY(-2%) rotate(2deg); }
  100% { transform: translateY(0) rotate(0); }
}

/* 撃破・到達・投げるモーション */
.zombie.dead .z-body {
  animation: z-dead 0.5s ease forwards;
}

@keyframes z-dead {
  0% { opacity: 1; transform: translateY(0) scale(1) rotate(0); }
  100% { opacity: 0; transform: translateY(20%) scale(0.55) rotate(24deg); }
}

.zombie.attacking .z-body {
  animation: z-attack 0.45s ease forwards;
}

@keyframes z-attack {
  0% { opacity: 1; transform: scale(1); }
  40% { opacity: 1; transform: translateY(6%) scale(1.35); }
  100% { opacity: 0; transform: translateY(12%) scale(1.6); }
}

.zombie.throwing .z-body {
  animation: z-throw 0.3s ease;
}

@keyframes z-throw {
  0% { transform: rotate(0); }
  40% { transform: translateY(-6%) rotate(-8deg); }
  100% { transform: rotate(0); }
}

/* 撃破の破片 */
.z-part {
  position: absolute;
  left: 0;
  top: 0;
  color: #F9E79F;
  font-weight: 700;
  pointer-events: none;
  z-index: 175;
  text-shadow: 0 0 6px rgba(249, 231, 159, 0.8);
  animation: part-fly 0.65s ease-out forwards;
}

@keyframes part-fly {
  0% { transform: translate(-50%, -50%); opacity: 1; }
  100% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) rotate(180deg); opacity: 0; }
}

/* ============================================
   吹き出し(お題)。本体とは別要素で常に前面
   ============================================ */
.z-bubble {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 50% 100%;
  will-change: transform;
  background: rgba(12, 6, 24, 0.84);
  border: 2px solid rgba(255, 255, 255, 0.28);
  border-radius: 10px;
  padding: 4px 9px 5px;
  color: #fff;
  text-align: center;
  white-space: nowrap;
  pointer-events: none;
  line-height: 1.2;
}

.z-bubble::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -7px;
  width: 10px;
  height: 10px;
  margin-left: -5px;
  background: inherit;
  border-right: 2px solid rgba(255, 255, 255, 0.28);
  border-bottom: 2px solid rgba(255, 255, 255, 0.28);
  transform: rotate(45deg);
}

.z-bubble .z-jp {
  display: block;
  font-size: 15px;
  font-weight: 700;
}

.z-bubble .z-romaji {
  display: block;
  font-family: 'Courier New', 'SF Mono', 'Consolas', monospace;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.06em;
  margin-top: 1px;
}

.r-done { color: #58D68D; }
.r-rest { color: #F4F6F7; }

.z-bubble.targeted {
  background: rgba(130, 22, 22, 0.92);
  border-color: #F1C40F;
  box-shadow: 0 0 14px rgba(241, 196, 15, 0.6);
}

.z-bubble.targeted::after {
  border-color: #F1C40F;
}

.z-bubble.targeted .r-rest { color: #FDEBD0; }

/* 目の前まで来た敵の吹き出しは赤く点滅(危険) */
.z-bubble.near:not(.targeted) {
  animation: bubble-danger 0.5s ease-in-out infinite alternate;
}

@keyframes bubble-danger {
  0% { border-color: rgba(255, 255, 255, 0.28); }
  100% { border-color: #E74C3C; box-shadow: 0 0 10px rgba(231, 76, 60, 0.7); }
}

/* クイズの選択肢 */
.z-bubble.choice {
  background: rgba(40, 14, 70, 0.92);
  border-color: #BB8FCE;
  padding: 3px 8px 4px;
}

.z-bubble.choice .z-jp { font-size: 14px; }
.z-bubble.choice .z-romaji { font-size: 12px; }

/* ============================================
   照準・発砲・被弾
   ============================================ */
.reticle {
  position: absolute;
  left: 50%;
  top: 78%;
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  border: 2px solid rgba(255, 255, 255, 0.6);
  border-radius: 50%;
  z-index: 150;
  pointer-events: none;
  transition: transform 0.08s linear;
}

.reticle::before,
.reticle::after {
  content: '';
  position: absolute;
  background: rgba(255, 255, 255, 0.6);
}

.reticle::before { left: 50%; top: -8px; width: 2px; height: calc(100% + 16px); margin-left: -1px; }
.reticle::after { top: 50%; left: -8px; height: 2px; width: calc(100% + 16px); margin-top: -1px; }

.reticle.locked {
  border-color: #E74C3C;
  animation: reticle-blink 0.5s ease-in-out infinite alternate;
}

.reticle.locked::before,
.reticle.locked::after { background: #E74C3C; }

@keyframes reticle-blink {
  0% { opacity: 1; }
  100% { opacity: 0.55; }
}

.zombie-stage.idle .reticle { display: none; }

.muzzle {
  position: absolute;
  left: 50%;
  bottom: -14px;
  width: 180px;
  height: 100px;
  margin-left: -90px;
  background: radial-gradient(ellipse at 50% 100%, rgba(255, 236, 140, 0.95), rgba(255, 150, 20, 0.5) 38%, rgba(255, 150, 20, 0) 70%);
  opacity: 0;
  z-index: 160;
  pointer-events: none;
}

.muzzle.flash {
  animation: muzzle 0.09s ease-out;
}

@keyframes muzzle {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

.spark {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  z-index: 170;
  pointer-events: none;
}

.spark::before {
  content: '';
  position: absolute;
  left: -6px;
  top: -6px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #FFF6C2;
  box-shadow: 0 0 12px 5px rgba(255, 220, 100, 0.9);
  animation: spark 0.22s ease-out forwards;
}

@keyframes spark {
  0% { transform: scale(0.4); opacity: 1; }
  100% { transform: scale(2.2); opacity: 0; }
}

/* 爪あと(被弾) */
.claw {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 180;
  pointer-events: none;
  opacity: 0;
}

.claw path {
  fill: none;
  stroke: #E74C3C;
  stroke-width: 14;
  stroke-linecap: round;
  stroke-dasharray: 520;
  stroke-dashoffset: 520;
}

.claw.show { animation: claw-fade 0.55s ease-out forwards; }
.claw.show path { animation: claw-draw 0.18s ease-out forwards; }
.claw.show path:nth-child(2) { animation-delay: 0.05s; }
.claw.show path:nth-child(3) { animation-delay: 0.1s; }

@keyframes claw-draw {
  0% { stroke-dashoffset: 520; }
  100% { stroke-dashoffset: 0; }
}

@keyframes claw-fade {
  0%, 45% { opacity: 0.85; }
  100% { opacity: 0; }
}

/* HPが少ないときの赤いビネット */
.vignette {
  position: absolute;
  inset: 0;
  z-index: 140;
  pointer-events: none;
  box-shadow: inset 0 0 80px 30px rgba(180, 0, 0, 0);
  transition: box-shadow 0.4s ease;
}

.zombie-stage.danger .vignette {
  animation: vignette-pulse 0.9s ease-in-out infinite alternate;
}

@keyframes vignette-pulse {
  0% { box-shadow: inset 0 0 70px 24px rgba(200, 0, 0, 0.35); }
  100% { box-shadow: inset 0 0 100px 44px rgba(200, 0, 0, 0.6); }
}

/* ============================================
   ステージ内HUD・ターゲットボード
   ============================================ */
.stage-hud {
  position: absolute;
  top: 8px;
  left: 8px;
  right: 8px;
  display: flex;
  gap: 6px;
  z-index: 190;
  pointer-events: none;
}

.hud-item {
  display: flex;
  align-items: baseline;
  gap: 5px;
  background: rgba(10, 4, 20, 0.62);
  border-radius: 8px;
  padding: 3px 9px;
  color: #fff;
}

.hud-label {
  font-size: 0.6rem;
  color: #CBB8E8;
  letter-spacing: 0.06em;
}

.hud-value {
  font-size: 1rem;
  font-weight: 800;
  line-height: 1.1;
}

.hud-hp .hud-value {
  display: flex;
  gap: 2px;
  align-items: center;
}

.hp-heart::before {
  content: '♥';
  font-size: 1rem;
  color: #FF5C6C;
  text-shadow: 0 0 6px rgba(255, 92, 108, 0.7);
}

.hp-heart.off::before {
  color: #4E3F5C;
  text-shadow: none;
}

.zombie-stage.idle .stage-hud { opacity: 0.35; }

.target-board {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 1.1rem 0.75rem 0.5rem;
  background: linear-gradient(180deg, rgba(6, 3, 14, 0) 0%, rgba(6, 3, 14, 0.85) 40%);
  text-align: center;
  z-index: 195;
  pointer-events: none;
}

.target-jp {
  font-size: 1.3rem;
  font-weight: 700;
  color: #fff;
  min-height: 1.7rem;
  line-height: 1.3;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
}

.target-jp.hint {
  font-size: 1rem;
  color: rgba(255, 255, 255, 0.75);
}

.target-romaji {
  font-family: 'Courier New', 'SF Mono', 'Consolas', monospace;
  font-size: 1.35rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  min-height: 1.6rem;
  word-break: break-all;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
}

.zombie-stage.idle .target-board { display: none; }

/* WAVEバナー・ボス登場 */
.wave-banner {
  position: absolute;
  top: 28%;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 2rem;
  font-weight: 800;
  color: #F1C40F;
  text-shadow: 0 3px 10px rgba(0, 0, 0, 0.7);
  opacity: 0;
  pointer-events: none;
  z-index: 200;
}

.wave-banner.boss { color: #FF6B6B; }
.wave-banner.clear { color: #7DF9FF; }

.wave-banner.show {
  animation: wave-banner 1.4s ease forwards;
}

@keyframes wave-banner {
  0% { opacity: 0; transform: scale(0.6); }
  20% { opacity: 1; transform: scale(1.1); }
  35% { transform: scale(1); }
  75% { opacity: 1; }
  100% { opacity: 0; transform: scale(1); }
}

/* カウントダウン */
.countdown {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  font-size: 2.6rem;
  font-weight: 800;
  color: #fff;
  background: rgba(0, 0, 0, 0.5);
  text-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
  z-index: 210;
}

.countdown.active {
  display: flex;
}

/* IME ON のヒント */
.ime-hint {
  position: absolute;
  top: 44px;
  left: 50%;
  transform: translateX(-50%);
  max-width: 92%;
  background: rgba(241, 196, 15, 0.95);
  color: #2C1A05;
  font-size: 0.78rem;
  font-weight: 700;
  padding: 6px 12px;
  border-radius: 8px;
  opacity: 0;
  transition: opacity 0.3s ease;
  z-index: 220;
  pointer-events: none;
}

.ime-hint.show { opacity: 1; }

/* スコアポップ(位置は inline transform、動きは margin/opacity で衝突を避ける) */
.score-pop {
  position: absolute;
  left: 0;
  top: 0;
  font-size: 1.15rem;
  font-weight: 800;
  color: #F1C40F;
  text-shadow: 0 2px 6px rgba(0, 0, 0, 0.7);
  pointer-events: none;
  white-space: nowrap;
  z-index: 205;
  animation: pop-rise 1s ease forwards;
}

.score-pop.pop-boss { color: #FF6B6B; font-size: 1.45rem; }
.score-pop.pop-perfect { color: #7DF9FF; }
.score-pop.pop-heal { color: #58D68D; }
.score-pop.pop-miss { color: #BDC3C7; font-size: 1rem; }

@keyframes pop-rise {
  0% { margin-top: 0; opacity: 0; }
  15% { opacity: 1; }
  100% { margin-top: -44px; opacity: 0; }
}

/* ============================================
   スタート画面(ステージ内)
   ============================================ */
.start-overlay {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  background: rgba(8, 3, 18, 0.72);
  z-index: 230;
}

.start-overlay.active { display: flex; }

.start-inner {
  text-align: center;
  color: #fff;
  max-width: 94%;
  padding: 0.5rem;
}

.start-title {
  font-size: 2rem;
  font-weight: 900;
  letter-spacing: 0.08em;
  color: #F1C40F;
  text-shadow: 0 3px 12px rgba(0, 0, 0, 0.8), 0 0 18px rgba(241, 196, 15, 0.35);
  line-height: 1.1;
}

.start-lead {
  margin: 0.35rem 0 0.75rem;
  font-size: 0.95rem;
}

.start-lead strong { color: #F9E79F; }

.start-steps {
  list-style: none;
  display: flex;
  gap: 8px;
  justify-content: center;
  margin: 0 0 0.9rem;
  padding: 0;
}

.start-steps li {
  flex: 1 1 0;
  max-width: 160px;
  background: rgba(255, 255, 255, 0.09);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 10px;
  padding: 8px 6px;
  font-size: 0.72rem;
  line-height: 1.4;
}

.step-icon {
  display: block;
  font-size: 1.4rem;
  margin-bottom: 2px;
}

.btn-start {
  font-size: 1.15rem;
  padding: 0.8rem 2.4rem;
  box-shadow: 0 4px 16px rgba(230, 126, 34, 0.5);
}

.start-sub {
  font-size: 0.7rem;
  opacity: 0.7;
  margin-top: 0.35rem;
}

.practice-label {
  display: block;
  margin-top: 0.55rem;
  font-size: 0.78rem;
  opacity: 0.92;
  cursor: pointer;
}

.practice-label input { vertical-align: middle; margin-right: 4px; }

/* スマホ用の隠し入力(ソフトキーボード呼び出し用) */
.mobile-input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  border: none;
  padding: 0;
}

/* アクション(ミュートのみ。.btn-mute は css/style.css の共通版) */
.game-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

/* ============================================
   結果オーバーレイ
   ============================================ */
.result-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 200;
  padding: 1rem;
  animation: overlay-appear 0.3s ease;
}

.result-overlay.active {
  display: flex;
}

@keyframes overlay-appear {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

.result-content {
  background: #fff;
  border-radius: 16px;
  padding: 1.5rem 1.4rem;
  max-width: 460px;
  width: 100%;
  max-height: 92vh;
  overflow-y: auto;
  text-align: center;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  animation: result-pop 0.4s ease;
}

@keyframes result-pop {
  0% { transform: scale(0.8); opacity: 0; }
  100% { transform: scale(1); opacity: 1; }
}

.result-content h2 {
  font-size: 1.35rem;
  color: #4A3B5C;
  margin-bottom: 0.4rem;
}

/* 称号 */
.result-title-badge {
  display: inline-block;
  background: linear-gradient(135deg, #6C3483, #2ECC71);
  color: #fff;
  font-size: 1.1rem;
  font-weight: 800;
  border-radius: 100px;
  padding: 0.4rem 1.4rem;
  margin-bottom: 0.6rem;
  letter-spacing: 0.1em;
  box-shadow: 0 3px 10px rgba(108, 52, 131, 0.35);
}

.result-score-row {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.6rem;
}

.result-score-label {
  font-size: 0.8rem;
  color: #999;
}

.result-score-value {
  font-size: 2.2rem;
  font-weight: 800;
  color: #4A3B5C;
  letter-spacing: 0.02em;
}

.result-bonus {
  font-size: 0.78rem;
  color: #E67E22;
  font-weight: 700;
  min-height: 1.1rem;
}

.result-practice {
  font-size: 0.75rem;
  color: #999;
  margin-top: 0.2rem;
}

.result-grades-wrap {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0.8rem 0;
}

.result-grades {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.4rem;
}

.grade-cell {
  background: #F5F0FA;
  border-radius: 8px;
  padding: 0.4rem 0.2rem;
}

.grade-label {
  display: block;
  font-size: 0.65rem;
  color: #999;
}

.grade-value {
  display: block;
  font-size: 1.4rem;
  font-weight: 900;
  line-height: 1.1;
}

.result-overall-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 64px;
}

.result-overall-label {
  font-size: 0.65rem;
  color: #999;
}

.result-overall {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  font-size: 1.8rem;
  font-weight: 900;
  color: #fff;
  background: #7F8C8D;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}

.grade-S { color: #E67E22; }
.grade-A { color: #27AE60; }
.grade-B { color: #2980B9; }
.grade-C { color: #7F8C8D; }
.grade-D { color: #95A5A6; }
.result-overall.grade-S { background: linear-gradient(135deg, #F39C12, #E74C3C); color: #fff; }
.result-overall.grade-A { background: #27AE60; color: #fff; }
.result-overall.grade-B { background: #2980B9; color: #fff; }
.result-overall.grade-C { background: #7F8C8D; color: #fff; }
.result-overall.grade-D { background: #95A5A6; color: #fff; }

.result-stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.result-stat {
  background: #F5F0FA;
  border-radius: 8px;
  padding: 0.55rem 0.4rem;
}

.result-stat:last-child:nth-child(odd) {
  grid-column: 1 / -1;
}

.result-stat-label {
  display: block;
  font-size: 0.72rem;
  color: #999;
  margin-bottom: 0.15rem;
}

.result-stat-value {
  display: block;
  font-size: 1.15rem;
  font-weight: 700;
  color: #4A3B5C;
}

.result-best {
  color: #E67E22;
  font-weight: 700;
  font-size: 1rem;
  margin-bottom: 0.8rem;
  animation: best-glow 1s ease-in-out infinite alternate;
}

@keyframes best-glow {
  0% { opacity: 0.7; }
  100% { opacity: 1; text-shadow: 0 0 10px rgba(230, 126, 34, 0.3); }
}

.result-content .btn-primary {
  margin-top: 0.25rem;
}

/* ============================================
   レスポンシブ
   ============================================ */
/* 1366×768 など背の低い画面では、画面下のアンカー広告(約130px)にターゲット表示が隠れないよう
   ステージの高さを 16:9 より少し詰める(幅はそのまま) */
@media (min-width: 601px) and (max-height: 820px) {
  .zombie-stage {
    aspect-ratio: auto;
    height: clamp(300px, calc(100vh - 400px), 405px);
  }
}

@media (max-width: 600px) {
  .zombie-stage { aspect-ratio: 4 / 3; }
  .stage-hud { gap: 4px; }
  .hud-item { padding: 2px 6px; }
  .hud-value { font-size: 0.85rem; }
  .z-bubble .z-jp { font-size: 13px; }
  .z-bubble .z-romaji { font-size: 11px; }
  .target-jp { font-size: 1.05rem; }
  .target-romaji { font-size: 1.05rem; }
  .start-title { font-size: 1.5rem; }
  .start-lead { font-size: 0.85rem; margin-bottom: 0.5rem; }
  .start-steps { gap: 5px; margin-bottom: 0.6rem; }
  .start-steps li { font-size: 0.62rem; padding: 6px 4px; }
  .step-icon { font-size: 1.1rem; }
  .btn-start { font-size: 1rem; padding: 0.65rem 1.8rem; }
  .wave-banner { font-size: 1.5rem; }
  .wave-tip { font-size: 0.72rem; padding: 6px 10px; bottom: 26%; }
  .result-stats { gap: 0.4rem; }
  .result-stat-value { font-size: 1rem; }
  .result-score-value { font-size: 1.8rem; }
  .grade-value { font-size: 1.15rem; }
}

@media (max-width: 420px) {
  .score-box { min-width: 48px; padding: 0.3rem 0.4rem; }
  .score-label { font-size: 0.58rem; }
  .score-value { font-size: 0.95rem; }
  .start-steps li:nth-child(3) { display: none; }
}

/* キーボード推奨の注意書き(小さい画面のみ・ステージの外) */
@media (max-width: 600px) {
  .game-actions::before {
    content: '※ キーボード付きの端末で遊ぶのがおすすめです';
    flex: 1;
    font-size: 0.7rem;
    color: #888;
  }
}

game.js — 投影・ロックオン・ボスを動かす頭脳

game.jsがこのゲームの頭脳です。前半はローマ字判定エンジン。「かな → 打ち方の候補の配列」のKANAテーブルと、入力を1キーずつ前方一致で絞り込むacceptKey()でできています(仕組みのくわしい解説は回転寿司タイピングのソースページにあります)。canAccept()は「このキーを受け付けられるか」を状態を変えずに調べる検査用の関数で、ロックオンの心臓部です。

自分視点の秘密はproject(z, lane)です。ゾンビは奥行きz(1=最奥、0=目の前)とレーンlane(-1〜1)だけを持ち、この関数が画面上の位置と大きさに変換します。奥では小さく地平線の近くに、手前に来るほど急に大きく(Math.powで非線形に)なるので、同じ速さでzを減らしているだけなのに「迫ってくる」ように見えます。3Dの計算はしていません。

ロックオンはhandleChar()にあります。狙い中の敵(target)がいればその敵だけに入力を渡し、いなければ全個体にcanAccept()で「このキーで打ち始められる?」と聞いて回り、zがいちばん小さい(=いちばん手前の)1体を新しいターゲットにします。1文字の投げ物だけは、ロックオン中でも割り込んで撃ち落とせる特別扱いです。ねらいを変えたいときはEscキーでロックオンを外せます。

ボスはBOSS_KINDSという「種類ごとの振る舞いの表」で作っています。長文を3フレーズ連続で打つlong、3択の答えを打つquiz、ミスすると即ダメージのacc、3秒でことばが入れ替わるswift。どれも登場時の準備setupを持ち、必要な種類だけがnext(1フレーズ打ち切ったとき)・onMiss(ミスしたとき)・onTick(毎フレーム)という決まった名前の関数を足しているので、ゲーム本体は種類を気にせず「フレーズを1本打ち切ったら next を呼ぶ」だけで済みます。新しいボスを足したくなったら、この表に1つ増やせばよい設計です。

クイズボスの問題は、buildQuizQuestions()がボスが出るたびに用意します。words.jsに書いた問題に、その場で作った問題を混ぜて出すしくみです。makeMathQuiz()はかけ算・たし算・ひき算を出して、答えの数を「にじゅうご」のような読みがなに直し、makeCategoryQuiz()はwords.jsの仲間分けから「このうち◯◯はどれ?」や「◯◯じゃないのはどれ?」を作ります。3つの選択肢は、最初に打つキーがかぶらないことをchoicesUsable()で確かめているので、1打目を打った時点でどの答えを選んだかがはっきり決まります。まちがった答えではボスにダメージが入らないので、でたらめに打っても倒せません。

スコアはscoreWord()で「打った文字数 × 敵の種類 × コンボ倍率 × 速さ倍率」。ロックオンしてから打ち切るまでの秒数から1秒あたりの打鍵数を出し、速いほど倍率が上がります。さらに、1回もミスしなければ1.2倍、敵をぎりぎりまで引きつけて(z < 0.2)倒せば1.1倍です。効果音は共通モジュールjs/game-audio.jsのGameAudio.sfx()で鳴らしています。1打ごとの音typeShotは打鍵のクリック音が中心で、近い敵やコンボが多いときほど強く鳴るようpowerを渡しています。前は低い銃声の音で、ノートパソコンのスピーカーではほとんど聞こえなかったからです。

ウェーブは討伐制です。waveQuota()で決まった数のゾンビを倒し切ると、clearWave()で「前進!」になって次のウェーブへ進みます。3の倍数のウェーブ(isBossWave())はボスだけが出るボス戦です。むずかしさを生のウェーブ番号ではなくcombatIndex()(ボス戦を数えない回数)で決めているのは、ボス戦のあとで急に敵が増える段差をなくすためです。さらに、5体倒すごとに上がり、攻撃を受けると下がるrank(1〜8)で出てくる間隔や敵の種類の割合を変えて、うまい人にはきびしく、苦戦している人にはやさしくなるようにしています。

ウェーブが進むと、道ばたから飛び出す待ち伏せ(spawnAmbush())、近づかずに骨を投げてくる投げ手(spawnLurker())、1キーで撃ち落とせる投げ物(spawnThrow())が加わり、HPが減ってくると救急箱(spawnItem())も出ます。目の前まで来た敵はすぐには殴らず、chargingの状態でしばらく振りかぶります(ランナーは1秒、ボスは2秒)。このあいだに打ち切ればダメージを受けないので、ぎりぎりでも最後まであきらめない理由になります。

ゾンビの絵はartFor()が選びます。SPRITE_COUNTSに枚数が書いてある種類は、spritesフォルダの画像(normal-1.pngなど)からランダムに1枚を使い、0の種類は髪型・服・口などの部品をpickVariant()で組み合わせた手描きSVGを使います。数を書きかえて画像を置くだけで、コードを変えずに絵を差しかえられるしくみです。コピーしたコードを画像なしで動かすときは、SPRITE_COUNTSの数を0にすると手描きSVGにもどります。背景はウェーブごとにSCENESの表から選ばれ(ボス戦はいつも「血の月」)、applyScene()がSVGを組み立ててstage-bgに入れかえます。道と地平線の位置をproject()と合わせてあるので、場所が変わっても遠近感はくずれません。

game.js
// ============================================
// ゾンビタイピング ロジック
// ============================================
//
// 夜の墓場の道の奥から、こちら(プレイヤー)へ向かって迫ってくる
// ゾンビたちを、頭上のお題をローマ字でタイプして撃つ一人称視点の
// サバイバルタイピングゲーム。
// 最初の1打で狙うゾンビが決まり(ロックオン)、1打ごとに1発撃ち、
// 打ち切るとそのゾンビを倒せる。目の前まで来られるとHPが減る。
//
// ローマ字判定は「si / shi」「ja / zya / jya」「っ=子音重ね」
// 「ん=n / nn」など複数の打ち方を全て受け付ける。
//
// 画面は Canvas ではなく DOM。ゾンビ1体=1つの div で、奥行き z(1=最奥, 0=目の前)と
// レーン lane(-1〜1)を transform(translate + scale)に投影して擬似3Dにしている。

(function () {
  'use strict';

  // ============================================
  // ローマ字変換テーブル(かな → 打ち方の候補)
  // 候補の先頭が画面に表示される「おすすめの打ち方」
  // ============================================
  var KANA = {
    'あ': ['a'], 'い': ['i', 'yi'], 'う': ['u', 'wu', 'whu'], 'え': ['e'], 'お': ['o'],
    'か': ['ka', 'ca'], 'き': ['ki'], 'く': ['ku', 'cu', 'qu'], 'け': ['ke'], 'こ': ['ko', 'co'],
    'さ': ['sa'], 'し': ['si', 'shi', 'ci'], 'す': ['su'], 'せ': ['se', 'ce'], 'そ': ['so'],
    'た': ['ta'], 'ち': ['ti', 'chi'], 'つ': ['tu', 'tsu'], 'て': ['te'], 'と': ['to'],
    'な': ['na'], 'に': ['ni'], 'ぬ': ['nu'], 'ね': ['ne'], 'の': ['no'],
    'は': ['ha'], 'ひ': ['hi'], 'ふ': ['fu', 'hu'], 'へ': ['he'], 'ほ': ['ho'],
    'ま': ['ma'], 'み': ['mi'], 'む': ['mu'], 'め': ['me'], 'も': ['mo'],
    'や': ['ya'], 'ゆ': ['yu'], 'よ': ['yo'],
    'ら': ['ra'], 'り': ['ri'], 'る': ['ru'], 'れ': ['re'], 'ろ': ['ro'],
    'わ': ['wa'], 'を': ['wo'], 'ん': ['nn', 'xn'],
    'が': ['ga'], 'ぎ': ['gi'], 'ぐ': ['gu'], 'げ': ['ge'], 'ご': ['go'],
    'ざ': ['za'], 'じ': ['zi', 'ji'], 'ず': ['zu'], 'ぜ': ['ze'], 'ぞ': ['zo'],
    'だ': ['da'], 'ぢ': ['di'], 'づ': ['du'], 'で': ['de'], 'ど': ['do'],
    'ば': ['ba'], 'び': ['bi'], 'ぶ': ['bu'], 'べ': ['be'], 'ぼ': ['bo'],
    'ぱ': ['pa'], 'ぴ': ['pi'], 'ぷ': ['pu'], 'ぺ': ['pe'], 'ぽ': ['po'],
    'きゃ': ['kya'], 'きゅ': ['kyu'], 'きょ': ['kyo'],
    'しゃ': ['sya', 'sha'], 'しゅ': ['syu', 'shu'], 'しょ': ['syo', 'sho'],
    'ちゃ': ['tya', 'cha', 'cya'], 'ちゅ': ['tyu', 'chu', 'cyu'], 'ちょ': ['tyo', 'cho', 'cyo'],
    'にゃ': ['nya'], 'にゅ': ['nyu'], 'にょ': ['nyo'],
    'ひゃ': ['hya'], 'ひゅ': ['hyu'], 'ひょ': ['hyo'],
    'みゃ': ['mya'], 'みゅ': ['myu'], 'みょ': ['myo'],
    'りゃ': ['rya'], 'りゅ': ['ryu'], 'りょ': ['ryo'],
    'ぎゃ': ['gya'], 'ぎゅ': ['gyu'], 'ぎょ': ['gyo'],
    'じゃ': ['zya', 'ja', 'jya'], 'じゅ': ['zyu', 'ju', 'jyu'], 'じょ': ['zyo', 'jo', 'jyo'],
    'びゃ': ['bya'], 'びゅ': ['byu'], 'びょ': ['byo'],
    'ぴゃ': ['pya'], 'ぴゅ': ['pyu'], 'ぴょ': ['pyo'],
    'ふぁ': ['fa'], 'ふぃ': ['fi'], 'ふぇ': ['fe'], 'ふぉ': ['fo'],
    'てぃ': ['thi'], 'でぃ': ['dhi'], 'でゅ': ['dhu'],
    'うぃ': ['wi'], 'うぇ': ['we'],
    'ぁ': ['xa', 'la'], 'ぃ': ['xi', 'li'], 'ぅ': ['xu', 'lu'], 'ぇ': ['xe', 'le'], 'ぉ': ['xo', 'lo'],
    'ゃ': ['xya', 'lya'], 'ゅ': ['xyu', 'lyu'], 'ょ': ['xyo', 'lyo'],
    'っ': ['xtu', 'ltu', 'ltsu'],
    'ー': ['-']
  };

  // 「ん」を1打の n で打てない(nn が必要な)次の文字の頭
  var N_NEEDS_DOUBLE = 'aiueony';

  // ============================================
  // お題ワード
  // short: 走るゾンビ / medium: ふつうゾンビ / long: でかゾンビ / boss: 長文ボス
  // quiz: クイズボスの3択 / item: 回復アイテム
  // ============================================
  // お題データは words.js(自動更新対象)から読み込む。各要素は { d: 表示, k: かな }
  var WORDS = (typeof module !== 'undefined' && module.exports)
    ? require('./words.js')
    : window.GAME_WORDS;

  // 敵・オブジェクトの種類。
  //   pool: 使うお題プール / speed: 標準ゾンビ比の速さ / mult: 得点倍率 / size: 見た目の基準倍率
  var TYPES = {
    runner: { pool: 'short', speed: 1.7, mult: 1.5, size: 0.82, label: '走るゾンビ' },
    normal: { pool: 'medium', speed: 1.0, mult: 1, size: 1.0, label: 'ゾンビ' },
    tank: { pool: 'long', speed: 0.55, mult: 2, size: 1.32, label: 'でかゾンビ' },
    boss: { pool: 'boss', speed: 0.36, mult: 3, size: 1.9, label: 'ボス' },
    swift: { pool: 'short', speed: 0.5, mult: 3, size: 0.75, label: 'すばやいボス' },
    lurker: { pool: 'medium', speed: 0, mult: 2, size: 1.0, label: '物陰の投げ手' },
    throw: { pool: null, speed: 3.2, mult: 0, size: 0.85, label: '投げ物' },
    item: { pool: 'item', speed: 1.3, mult: 0, size: 0.62, label: '回復アイテム' }
  };
  var TRAVEL_SECONDS = 13;  // 標準ゾンビが最奥から目の前まで歩く秒数(ウェーブ1)

  // 称号(スコアの高い順に判定)
  var TITLES = [
    { min: 120000, name: '夜明けをつれてくる者' },
    { min: 90000, name: 'ゾンビキング討伐者' },
    { min: 65000, name: '伝説のハンター' },
    { min: 45000, name: '墓場の番人' },
    { min: 30000, name: '夜の守り人' },
    { min: 18000, name: 'ゾンビバスター' },
    { min: 10000, name: '村の用心棒' },
    { min: 5000, name: '見回り隊員' },
    { min: 2000, name: '見習いハンター' },
    { min: 0, name: '夜道デビュー' }
  ];

  var MAX_HP = 5;
  // ウェーブは討伐制: ノルマの数を倒し切ると「クリア」→ 前進 → 次のウェーブ。
  // 3の倍数のウェーブはボス戦(ボスだけが出る。他の敵は出さない)
  var FIRST_BOSS_WAVE = 3;
  var BOSS_EVERY = 3;
  function isBossWave(w) { return w >= FIRST_BOSS_WAVE && (w - FIRST_BOSS_WAVE) % BOSS_EVERY === 0; }
  function bossesBeforeWave(w) { return w >= FIRST_BOSS_WAVE ? Math.floor((w - FIRST_BOSS_WAVE) / BOSS_EVERY) + 1 : 0; }

  // 難易度は「戦闘ウェーブが何回目か」で決める。
  // ボス戦は敵を出さない休憩なのに wave 番号だけ進むので、生の番号で難易度を上げると
  // wave2 → wave4 で同時出現が 2→6 に跳ねていた(実測でも wave4 の死亡が突出)。
  // ボス戦を数えないことで、この段差が消える
  function combatIndex(w) {
    return Math.max(1, w - bossesBeforeWave(w));
  }
  function waveQuota(w) { return Math.min(4 + combatIndex(w) * 2, 18); }   // そのウェーブで出るゾンビの数

  // でかゾンビ(c>=3)・待ち伏せ(c>=4)・投げ物(c>=5)・物陰の投げ手(c>=7)の解禁しきい値は、
  // ボスがwave番号だけ消費するせいで毎回ボスの直後2ウェーブ(wave4/5, wave7/8...)に集中してしまう。
  // 「新しい敵の挙動」と「同時出現の増加」が同時に来て、実測でも死亡がここに突出していた
  // (ボス戦の死亡率0.5〜0.8%に対し直後2ウェーブは14〜19%)。この2ウェーブだけ物量を1段緩める
  function isPostBossWave(w) { return isBossWave(w - 1) || isBossWave(w - 2); }
  // 2026-08-25のこの緩和はボス1体目の直後(wave4/5)にしか効いていなかった。ボス2体目以降は
  // combatIndexがさらに1段高く(wave7で5, wave8で6)、ちょうどそこで投げ物・物陰の投げ手が
  // 初解禁になって重なるため、同じ緩和幅では足りなかった(実測: wave7 43.7%→42.5%、
  // wave8 76.6%→72.2%と死亡率がほぼ変わらず)。2体目以降はより強く緩和する
  function isHardPostBossWave(w) { return isPostBossWave(w) && bossesBeforeWave(w) >= 2; }

  // ============================================
  // ローマ字エンジン(回転寿司タイピングと同じ仕組み)
  // ============================================

  function dedupe(arr) {
    var seen = {};
    return arr.filter(function (s) {
      if (seen[s]) return false;
      seen[s] = true;
      return true;
    });
  }

  // かな文字列をトークン列に分解する。
  // 「っ」「ん」は次のかなと結合した1トークンにして、
  // 「子音重ね」「1打のn」を候補として正しく扱う。
  function tokenize(kana) {
    var units = [];
    var i = 0;
    while (i < kana.length) {
      var two = kana.substr(i, 2);
      if (two.length === 2 && KANA[two]) {
        units.push(two);
        i += 2;
      } else {
        units.push(kana.charAt(i));
        i += 1;
      }
    }

    var tokens = [];
    for (var j = 0; j < units.length; j++) {
      var u = units[j];
      var next = units[j + 1];
      var cands;

      if (u === 'っ' && next && KANA[next]) {
        cands = [];
        KANA[next].forEach(function (s) {
          var c = s.charAt(0);
          if ('aiueon'.indexOf(c) === -1) cands.push(c + s);
        });
        KANA['っ'].forEach(function (p) {
          KANA[next].forEach(function (s) { cands.push(p + s); });
        });
        tokens.push({ kana: 'っ' + next, candidates: dedupe(cands) });
        j++;
      } else if (u === 'ん' && next && KANA[next]) {
        cands = [];
        KANA[next].forEach(function (s) {
          if (N_NEEDS_DOUBLE.indexOf(s.charAt(0)) === -1) cands.push('n' + s);
        });
        KANA[next].forEach(function (s) {
          cands.push('nn' + s);
          cands.push('xn' + s);
        });
        tokens.push({ kana: 'ん' + next, candidates: dedupe(cands) });
        j++;
      } else {
        tokens.push({ kana: u, candidates: (KANA[u] || [u]).slice() });
      }
    }
    return tokens;
  }

  function bestCandidate(token, typed) {
    for (var i = 0; i < token.candidates.length; i++) {
      if (token.candidates[i].indexOf(typed) === 0) return token.candidates[i];
    }
    return token.candidates[0];
  }

  function createWordState(kana) {
    return { tokens: tokenize(kana), ti: 0, typed: '' };
  }

  function estimateKeys(state) {
    var n = 0;
    for (var i = 0; i < state.tokens.length; i++) {
      n += state.tokens[i].candidates[0].length;
    }
    return n;
  }

  // このキーを受け付けられるか(状態は変えない)
  function canAccept(state, ch) {
    var token = state.tokens[state.ti];
    if (!token) return false;
    var pref = state.typed + ch;
    return token.candidates.some(function (c) { return c.indexOf(pref) === 0; });
  }

  // 1キー入力を処理。true=正解 / false=ミス
  function acceptKey(state, ch) {
    if (!canAccept(state, ch)) return false;
    var token = state.tokens[state.ti];
    state.typed += ch;
    if (token.candidates.indexOf(state.typed) !== -1) {
      token.done = state.typed;
      state.ti++;
      state.typed = '';
    }
    return true;
  }

  function isWordComplete(state) {
    return state.ti >= state.tokens.length;
  }

  // 表示用ローマ字: [打ち終えた部分, これから打つ部分]
  function romajiParts(state) {
    var done = '';
    var rest = '';
    for (var i = 0; i < state.tokens.length; i++) {
      var t = state.tokens[i];
      if (i < state.ti) {
        done += t.done;
      } else if (i === state.ti) {
        var cand = bestCandidate(t, state.typed);
        done += state.typed;
        rest += cand.slice(state.typed.length);
      } else {
        rest += t.candidates[0];
      }
    }
    return [done, rest];
  }

  // 打ち終えたキー数(スコア計算用)
  function doneKeys(state) {
    var n = 0;
    for (var i = 0; i < state.ti && i < state.tokens.length; i++) n += (state.tokens[i].done || '').length;
    return n + state.typed.length;
  }

  // Node.js でのテスト用エクスポート(ブラウザでは無視される)
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = {
      tokenize: tokenize,
      createWordState: createWordState,
      canAccept: canAccept,
      acceptKey: acceptKey,
      isWordComplete: isWordComplete,
      romajiParts: romajiParts,
      estimateKeys: estimateKeys,
      WORDS: WORDS
    };
    return;
  }

  // ============================================
  // ここからブラウザ用のゲーム本体
  // ============================================

  // ---- 手描きSVG(絵文字は環境で絵柄が変わるうえ正面向きの絵がないため自前で描く)----
  // 同時に何体も出るので gradient/filter の id は使わない(id が重複すると別の個体の絵が壊れる)。
  // ゾンビは「肌・髪・服・小物・口」を部品にして組み合わせるので、同じ種類でも1体ずつ見た目が違う。
  var SVG_OPEN = '<svg viewBox="0 0 64 80" aria-hidden="true">';

  var PALETTE = {
    skin: ['#8CC084', '#9FB6A3', '#A6D3B0', '#B9A8D9', '#C7C98E', '#7FB8B0'],
    hair: ['#2E4053', '#6C3483', '#7B241C', '#1B2631', '#935116', '#5D6D7E'],
    shirt: [['#5D6D7E', '#3E4C5A'], ['#C0392B', '#922B21'], ['#2471A3', '#1A5276'], ['#7D6608', '#5B4A05'], ['#7D3C98', '#5B2C6F'], ['#148F77', '#0E6655'], ['#F39C12', '#B9770E']],
    pants: ['#2E4053', '#4A235A', '#17202A', '#6E2C00', '#1F618D']
  };
  var HAIR_STYLES = ['messy', 'side', 'bald', 'bob', 'mohawk'];
  var ACCESSORIES = ['none', 'none', 'cap', 'glasses', 'bandage', 'bow', 'headphones'];
  var SHIRT_STYLES = ['torn', 'uniform', 'stripes'];
  var MOUTHS = ['teeth', 'grin', 'tongue', 'oh'];

  function rnd(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

  function pickVariant() {
    return {
      skin: rnd(PALETTE.skin), hair: rnd(PALETTE.hair), shirt: rnd(PALETTE.shirt), pants: rnd(PALETTE.pants),
      hairStyle: rnd(HAIR_STYLES), acc: rnd(ACCESSORIES), shirtStyle: rnd(SHIRT_STYLES), mouth: rnd(MOUTHS),
      eyeFlip: Math.random() < 0.5
    };
  }

  // 頭(肌・目・口・縫い目・髪・小物)。cx,cy は頭の中心、rx,ry は大きさ
  function headSvg(v, cx, cy, rx, ry, opts) {
    opts = opts || {};
    var s = '';
    var big = v.eyeFlip ? -1 : 1;             // どちらの目が大きいか(左右で違うと「ゾンビ顔」になる)
    var exL = cx - rx * 0.42, exR = cx + rx * 0.42, ey = cy + ry * 0.05;
    // 髪(頭の後ろに描くものは先に)
    if (v.hairStyle === 'bob') {
      s += '<path d="M' + (cx - rx - 1) + ' ' + (cy + ry * 0.4) + 'q0-' + (ry * 1.5) + ' ' + (rx + 1) + '-' + (ry * 1.5) + 'q' + (rx + 1) + ' 0 ' + (rx + 1) + ' ' + (ry * 1.5) + 'v' + (ry * 0.5) + 'h-4v-' + (ry * 0.6) + 'q-' + (rx - 3) + '-' + (ry * 0.6) + '-' + ((rx - 3) * 2) + ' 0v' + (ry * 0.6) + 'z" fill="' + v.hair + '"/>';
    }
    s += '<ellipse cx="' + cx + '" cy="' + cy + '" rx="' + rx + '" ry="' + ry + '" fill="' + v.skin + '"/>';
    // あご・ほほのくぼみ(少しだけ影)
    s += '<path d="M' + (cx - rx * 0.6) + ' ' + (cy + ry * 0.55) + 'q' + (rx * 0.6) + ' ' + (ry * 0.5) + ' ' + (rx * 1.2) + ' 0" fill="none" stroke="rgba(0,0,0,0.12)" stroke-width="2"/>';
    // 縫い目
    s += '<path d="M' + (cx - rx * 0.85) + ' ' + (cy + ry * 0.35) + 'l6 1.5M' + (cx - rx * 0.7) + ' ' + (cy + ry * 0.2) + 'v5" stroke="rgba(0,0,0,0.35)" stroke-width="1.3" stroke-linecap="round"/>';
    // 目(片方大きく、瞳は少しずらして焦点の合っていない感じ)
    var rBig = rx * 0.34, rSmall = rx * 0.24;
    var rL = big > 0 ? rSmall : rBig, rR = big > 0 ? rBig : rSmall;
    s += '<circle cx="' + exL + '" cy="' + ey + '" r="' + rL + '" fill="#fff"/><circle cx="' + exR + '" cy="' + (ey - 1) + '" r="' + rR + '" fill="#fff"/>';
    s += '<circle cx="' + (exL + 0.8) + '" cy="' + (ey + 0.6) + '" r="' + (rL * 0.5) + '" fill="#1B2631"/><circle cx="' + (exR - 0.6) + '" cy="' + (ey - 0.4) + '" r="' + (rR * 0.45) + '" fill="#1B2631"/>';
    // まゆ(片方だけ吊り上げ)
    s += '<path d="M' + (exL - rL) + ' ' + (ey - rL - 2) + 'l' + (rL * 2) + ' -1.5" stroke="rgba(0,0,0,0.5)" stroke-width="1.6" stroke-linecap="round"/>';
    // 口
    var my = cy + ry * 0.62;
    if (v.mouth === 'teeth') {
      s += '<path d="M' + (cx - 7) + ' ' + my + 'q7 6 14 0z" fill="#1B2631"/><path d="M' + (cx - 4) + ' ' + (my + 0.5) + 'v2.2M' + cx + ' ' + (my + 1.5) + 'v2.4M' + (cx + 4) + ' ' + (my + 0.5) + 'v2.2" stroke="#F4F6F7" stroke-width="1.6" stroke-linecap="round"/>';
    } else if (v.mouth === 'grin') {
      s += '<path d="M' + (cx - 7) + ' ' + my + 'q7 5 14 0" stroke="#1B2631" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M' + (cx + 3) + ' ' + (my + 1) + 'l1.5 3 1.5-3z" fill="#F4F6F7"/>';
    } else if (v.mouth === 'tongue') {
      s += '<path d="M' + (cx - 6) + ' ' + my + 'q6 4 12 0z" fill="#1B2631"/><ellipse cx="' + (cx + 2) + '" cy="' + (my + 3) + '" rx="3" ry="2.6" fill="#E57373"/>';
    } else {
      s += '<ellipse cx="' + cx + '" cy="' + (my + 1) + '" rx="3.2" ry="4" fill="#1B2631"/>';
    }
    // 髪(前)
    var top = cy - ry;
    if (v.hairStyle === 'messy') {
      s += '<path d="M' + (cx - rx + 1) + ' ' + (top + 7) + 'l3-7 3 5 3-8 3 6 3-8 3 6 3-7 3 5 2-6 2 8-2 3q-14-5-27 0z" fill="' + v.hair + '"/>';
    } else if (v.hairStyle === 'side') {
      s += '<path d="M' + (cx - rx) + ' ' + (top + 9) + 'q4-11 16-9 8 1 11 8-6-2-12 0-9-2-15 1z" fill="' + v.hair + '"/>';
    } else if (v.hairStyle === 'mohawk') {
      s += '<path d="M' + (cx - 4) + ' ' + (top + 3) + 'l2-9 2 6 2-8 2 11z" fill="' + v.hair + '"/>';
    } else if (v.hairStyle === 'bald') {
      s += '<path d="M' + (cx - 5) + ' ' + (top + 2) + 'l-1-4M' + (cx + 1) + ' ' + (top + 1) + 'l1-4M' + (cx + 6) + ' ' + (top + 3) + 'l2-3" stroke="' + v.hair + '" stroke-width="1.5" stroke-linecap="round"/>';
    }
    // 小物
    if (v.acc === 'cap' && !opts.noHat) {
      s += '<path d="M' + (cx - rx + 1) + ' ' + (top + 6) + 'q' + (rx - 1) + '-11 ' + ((rx - 1) * 2) + ' 0z" fill="#1F3A5F"/><rect x="' + (cx - rx - 3) + '" y="' + (top + 5) + '" width="' + (rx + 8) + '" height="3" rx="1.5" fill="#1F3A5F"/><circle cx="' + cx + '" cy="' + (top + 1) + '" r="1.5" fill="#F1C40F"/>';
    } else if (v.acc === 'glasses') {
      s += '<circle cx="' + exL + '" cy="' + ey + '" r="' + (rBig + 1.5) + '" fill="none" stroke="#1B2631" stroke-width="1.5"/><circle cx="' + exR + '" cy="' + (ey - 1) + '" r="' + (rBig + 1.5) + '" fill="none" stroke="#1B2631" stroke-width="1.5"/><path d="M' + (exL + rBig + 1.5) + ' ' + ey + 'h' + (exR - exL - (rBig + 1.5) * 2) + '" stroke="#1B2631" stroke-width="1.5"/>';
    } else if (v.acc === 'bandage') {
      s += '<path d="M' + (cx - rx) + ' ' + (cy - ry * 0.35) + 'q' + rx + '-6 ' + (rx * 2) + ' -2v4q-' + rx + '-4-' + (rx * 2) + ' 2z" fill="#F4F6F7"/><path d="M' + (cx - 3) + ' ' + (cy - ry * 0.4) + 'l3 3M' + cx + ' ' + (cy - ry * 0.4) + 'l-3 3" stroke="#B0B0B0" stroke-width="1"/>';
    } else if (v.acc === 'bow' && !opts.noHat) {
      s += '<path d="M' + (cx - rx + 4) + ' ' + (top + 5) + 'l-6-4v8zM' + (cx - rx + 4) + ' ' + (top + 5) + 'l6-4v8z" fill="#E74C3C"/><circle cx="' + (cx - rx + 4) + '" cy="' + (top + 5) + '" r="1.6" fill="#C0392B"/>';
    } else if (v.acc === 'headphones') {
      s += '<path d="M' + (cx - rx - 1) + ' ' + cy + 'q0-' + (ry + 4) + ' ' + (rx + 1) + '-' + (ry + 4) + 'q' + (rx + 1) + ' 0 ' + (rx + 1) + ' ' + (ry + 4) + '" fill="none" stroke="#1B2631" stroke-width="2.4"/><ellipse cx="' + (cx - rx - 1) + '" cy="' + cy + '" rx="3" ry="4" fill="#E74C3C"/><ellipse cx="' + (cx + rx + 1) + '" cy="' + cy + '" rx="3" ry="4" fill="#E74C3C"/>';
    }
    return s;
  }

  // 胴体(服のスタイル違い)。x1〜x2 が幅、y1〜y2 が高さ
  function torsoSvg(v, x1, y1, x2, y2) {
    var w = x2 - x1, s = '';
    var c = v.shirt[0], d = v.shirt[1];
    if (v.shirtStyle === 'uniform') {
      s += '<path d="M' + x1 + ' ' + y1 + 'h' + w + 'v' + (y2 - y1) + 'H' + x1 + 'z" fill="#2C3E50"/>';
      s += '<path d="M' + (x1 + w * 0.32) + ' ' + y1 + 'l' + (w * 0.18) + ' ' + (w * 0.3) + ' ' + (w * 0.18) + '-' + (w * 0.3) + 'z" fill="#F4F6F7"/>';
      s += '<path d="M' + (x1 + w * 0.46) + ' ' + (y1 + w * 0.2) + 'l' + (w * 0.08) + ' ' + (w * 0.5) + ' -' + (w * 0.04) + ' ' + (w * 0.15) + ' -' + (w * 0.08) + '-' + (w * 0.15) + 'z" fill="' + c + '"/>';
    } else if (v.shirtStyle === 'stripes') {
      s += '<path d="M' + x1 + ' ' + y1 + 'h' + w + 'v' + (y2 - y1) + 'H' + x1 + 'z" fill="' + c + '"/>';
      for (var i = 0; i < 3; i++) {
        s += '<rect x="' + x1 + '" y="' + (y1 + 4 + i * 8) + '" width="' + w + '" height="3.5" fill="' + d + '"/>';
      }
    } else {
      s += '<path d="M' + x1 + ' ' + y1 + 'h' + w + 'v' + (y2 - y1) + 'H' + x1 + 'z" fill="' + c + '"/>';
      // 破れ
      s += '<path d="M' + (x1 + w * 0.15) + ' ' + (y1 + 8) + 'l3 6-2 5M' + (x1 + w * 0.7) + ' ' + (y1 + 14) + 'l-3 5 2 6" stroke="' + d + '" stroke-width="2" fill="none" stroke-linecap="round"/>';
    }
    // すその破れ(共通)
    var hem = 'M' + x1 + ' ' + y2;
    var steps = 6, sw = w / steps;
    for (var j = 0; j < steps; j++) hem += 'l' + (sw / 2) + ' -4l' + (sw / 2) + ' 4';
    hem += 'v3H' + x1 + 'z';
    s += '<path d="' + hem + '" fill="' + (v.shirtStyle === 'uniform' ? '#1B2631' : d) + '"/>';
    return s;
  }

  function armsSvg(v, y, opts) {
    opts = opts || {};
    var s = '';
    // 前に突き出した両腕(袖+肌)。runner は片腕上げ
    if (opts.stride) {
      s += '<g transform="rotate(-25 24 ' + (y + 1) + ')"><rect x="12" y="' + (y - 4) + '" width="24" height="8" rx="4" fill="' + v.skin + '"/><rect x="24" y="' + (y - 4) + '" width="10" height="8" rx="3" fill="' + v.shirt[0] + '"/><circle cx="12" cy="' + y + '" r="4.5" fill="' + v.skin + '"/></g>';
      s += '<g transform="rotate(15 42 ' + (y + 5) + ')"><rect x="30" y="' + y + '" width="24" height="8" rx="4" fill="' + v.skin + '"/><rect x="30" y="' + y + '" width="10" height="8" rx="3" fill="' + v.shirt[0] + '"/><circle cx="55" cy="' + (y + 4) + '" r="4.5" fill="' + v.skin + '"/></g>';
      return s;
    }
    var hw = opts.wide ? 12 : 9;
    s += '<rect x="2" y="' + y + '" width="26" height="' + hw + '" rx="' + (hw / 2) + '" fill="' + v.skin + '"/><rect x="36" y="' + y + '" width="26" height="' + hw + '" rx="' + (hw / 2) + '" fill="' + v.skin + '"/>';
    s += '<rect x="17" y="' + y + '" width="11" height="' + hw + '" rx="2" fill="' + v.shirt[0] + '"/><rect x="36" y="' + y + '" width="11" height="' + hw + '" rx="2" fill="' + v.shirt[0] + '"/>';
    s += '<circle cx="4" cy="' + (y + hw / 2) + '" r="' + (hw / 2 + 1) + '" fill="' + v.skin + '"/><circle cx="60" cy="' + (y + hw / 2) + '" r="' + (hw / 2 + 1) + '" fill="' + v.skin + '"/>';
    // 指
    s += '<path d="M1 ' + (y + hw / 2 - 2) + 'l-1-3M3 ' + (y + hw / 2 - 3) + 'l0-3M63 ' + (y + hw / 2 - 2) + 'l1-3M61 ' + (y + hw / 2 - 3) + 'l0-3" stroke="' + v.skin + '" stroke-width="2" stroke-linecap="round"/>';
    return s;
  }

  function legsSvg(v, y, opts) {
    opts = opts || {};
    var s = '';
    if (opts.stride) {
      s += '<rect x="18" y="' + y + '" width="9" height="20" rx="2" fill="' + v.pants + '" transform="rotate(18 22 ' + y + ')"/><rect x="36" y="' + y + '" width="9" height="20" rx="2" fill="' + v.pants + '" transform="rotate(-18 40 ' + y + ')"/>';
      s += '<ellipse cx="17" cy="79" rx="6" ry="2.5" fill="#1B2631"/><ellipse cx="47" cy="79" rx="6" ry="2.5" fill="#1B2631"/>';
      return s;
    }
    var lw = opts.wide ? 13 : 9, gap = opts.wide ? 8 : 4;
    var lx = 32 - gap / 2 - lw, rx = 32 + gap / 2;
    s += '<rect x="' + lx + '" y="' + y + '" width="' + lw + '" height="' + (79 - y) + '" rx="2" fill="' + v.pants + '"/><rect x="' + rx + '" y="' + y + '" width="' + lw + '" height="' + (79 - y) + '" rx="2" fill="' + v.pants + '"/>';
    // ひざの破れと靴(片方だけ靴下)
    s += '<path d="M' + (lx + 2) + ' ' + (y + 8) + 'h' + (lw - 4) + '" stroke="rgba(0,0,0,0.25)" stroke-width="1.5"/>';
    s += '<ellipse cx="' + (lx + lw / 2) + '" cy="79" rx="' + (lw / 2 + 2) + '" ry="2.5" fill="#1B2631"/><ellipse cx="' + (rx + lw / 2) + '" cy="79" rx="' + (lw / 2 + 2) + '" ry="2.5" fill="#F4F6F7"/>';
    return s;
  }

  var ART = {
    normal: function (v) {
      return SVG_OPEN +
        armsSvg(v, 33) +
        torsoSvg(v, 19, 31, 45, 60) +
        legsSvg(v, 58) +
        headSvg(v, 32, 19, 14, 15) +
        '</svg>';
    },
    // 走るゾンビ: 細身で前傾、片腕を振り上げて足は大股
    runner: function (v) {
      return SVG_OPEN + '<g transform="rotate(-8 32 78)">' +
        armsSvg(v, 34, { stride: true }) +
        torsoSvg(v, 22, 30, 42, 58) +
        '<path d="M22 30h20v4H22z" fill="' + v.shirt[1] + '" opacity="0.6"/>' +
        legsSvg(v, 58, { stride: true }) +
        headSvg(v, 33, 17, 12.5, 13.5) +
        '</g></svg>';
    },
    // でかゾンビ: 横幅の巨体、オーバーオール、大きなあごに歯
    tank: function (v) {
      var body = '<path d="M11 32h42l4 34H7z" fill="' + v.shirt[0] + '"/>' +
        '<path d="M20 32v-6h6v6M38 32v-6h6v6" fill="none" stroke="' + v.shirt[1] + '" stroke-width="3"/>' +
        '<rect x="24" y="36" width="16" height="12" rx="2" fill="' + v.shirt[1] + '" opacity="0.7"/>' +
        '<path d="M14 62q18 6 36 0" stroke="rgba(0,0,0,0.25)" stroke-width="2" fill="none"/>';
      return SVG_OPEN +
        armsSvg(v, 36, { wide: true }) +
        body +
        legsSvg(v, 64, { wide: true }) +
        headSvg(v, 32, 20, 17, 16, { noHat: false }) +
        '<path d="M22 31q10 5 20 0v4q-10 3-20 0z" fill="#1B2631"/><path d="M26 32v3M32 33v3M38 32v3" stroke="#F4F6F7" stroke-width="2" stroke-linecap="round"/>' +
        '</svg>';
    },
    // ボス: 王冠+マント。肌は変えるがシルエットは固定
    boss: function (v) {
      var bv = { skin: v.skin, hair: '#1B2631', shirt: ['#1C2833', '#0B0F14'], pants: '#17202A', hairStyle: 'bald', acc: 'none', shirtStyle: 'torn', mouth: 'teeth', eyeFlip: v.eyeFlip };
      return SVG_OPEN +
        '<path d="M8 30l24 6 24-6 4 48H4z" fill="#4A235A"/><path d="M8 78l6-8 5 8 5-8 5 8 6-8 5 8 5-8 5 8 6-8 4 8z" fill="#3B1C4A"/><path d="M12 30l20 5 20-5" stroke="#7D3C98" stroke-width="2" fill="none"/>' +
        armsSvg(bv, 34, { wide: true }) +
        '<path d="M18 30h28v34H18z" fill="#1C2833"/><path d="M22 34h20v3H22zM22 40h20v3H22z" fill="#F1C40F" opacity="0.75"/><path d="M18 64l4-4 4 4 4-4 4 4 4-4 4 4 4-4v4H18z" fill="#0B0F14"/>' +
        legsSvg(bv, 62) +
        headSvg(bv, 32, 18, 15, 15) +
        '<path d="M18 9l5 5 4-8 5 8 5-8 4 8 5-5v7H18z" fill="#F1C40F"/><circle cx="32" cy="7" r="2" fill="#E74C3C"/><circle cx="24" cy="12" r="1.3" fill="#2ECC71"/><circle cx="40" cy="12" r="1.3" fill="#2ECC71"/>' +
        '<circle cx="26.5" cy="18.8" r="1.8" fill="#C0392B"/><circle cx="38.2" cy="17.4" r="2" fill="#C0392B"/>' +
        '</svg>';
    },
    swift: SVG_OPEN +
      '<path d="M4 34c8-12 20-12 26-4-6-2-10 2-8 8-4-4-12-4-18-4z" fill="#5B2C6F"/>' +
      '<path d="M60 34c-8-12-20-12-26-4 6-2 10 2 8 8 4-4 12-4 18-4z" fill="#5B2C6F"/>' +
      '<ellipse cx="32" cy="42" rx="14" ry="16" fill="#8E44AD"/>' +
      '<circle cx="26" cy="38" r="5" fill="#fff"/><circle cx="38" cy="38" r="5" fill="#fff"/>' +
      '<circle cx="27" cy="39" r="2.4" fill="#1B2631"/><circle cx="39" cy="39" r="2.4" fill="#1B2631"/>' +
      '<path d="M26 50q6 4 12 0" stroke="#1B2631" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M29 51l1.5 3 1.5-3zM33 51l1.5 3 1.5-3z" fill="#F4F6F7"/>' +
      '<path d="M22 24l4 6M42 24l-4 6" stroke="#5B2C6F" stroke-width="3" stroke-linecap="round"/>' +
      '<path d="M28 57l4 6 4-6" fill="#8E44AD"/></svg>',
    // 投げ物(骨)。回転は CSS で
    throw: '<svg viewBox="0 0 64 64" aria-hidden="true">' +
      '<path d="M14 26a6 6 0 1 1 6-8l24 24a6 6 0 1 1-8 6l-2-2a6 6 0 1 1-6-6z" fill="#F7F9F9" stroke="#AAB7B8" stroke-width="2"/>' +
      '<circle cx="14" cy="14" r="6" fill="#F7F9F9" stroke="#AAB7B8" stroke-width="2"/><circle cx="50" cy="50" r="6" fill="#F7F9F9" stroke="#AAB7B8" stroke-width="2"/></svg>',
    // 回復アイテム(救急箱)
    item: '<svg viewBox="0 0 64 64" aria-hidden="true">' +
      '<rect x="8" y="18" width="48" height="36" rx="6" fill="#F8F9F9" stroke="#D5D8DC" stroke-width="2"/>' +
      '<rect x="24" y="12" width="16" height="8" rx="3" fill="#BDC3C7"/>' +
      '<rect x="27" y="24" width="10" height="24" rx="2" fill="#E74C3C"/><rect x="20" y="31" width="24" height="10" rx="2" fill="#E74C3C"/></svg>'
  };

  // 画像で差し替える場合: games/zombie-typing/sprites/{type}-{n}.png(n=1..count)を置いて数を書く。
  // 0 のあいだは上の手描きSVGを使う。透過PNG・正面向き・足元が下端に接する構図が前提
  var SPRITE_COUNTS = { normal: 6, runner: 4, tank: 3, boss: 1, swift: 1, lurker: 2 };
  ART.lurker = function (v) { return ART.normal(v); };

  function artFor(typeName) {
    var n = SPRITE_COUNTS[typeName] || 0;
    if (n > 0) {
      var i = 1 + Math.floor(Math.random() * n);
      return '<img class="z-art" src="sprites/' + typeName + '-' + i + '.png" alt="" draggable="false">';
    }
    var a = ART[typeName];
    var svg = typeof a === 'function' ? a(pickVariant()) : a;
    return svg.replace('<svg ', '<svg class="z-art" ');
  }

  var els = {
    score: document.getElementById('score'),
    best: document.getElementById('best-score'),
    hudWave: document.getElementById('hud-wave'),
    hudHp: document.getElementById('hud-hp'),
    hudCombo: document.getElementById('hud-combo'),
    hudKills: document.getElementById('hud-kills'),
    field: document.getElementById('zombie-field'),
    stage: document.getElementById('zombie-stage'),
    stageBg: document.getElementById('stage-bg'),
    reticle: document.getElementById('reticle'),
    muzzle: document.getElementById('muzzle'),
    claw: document.getElementById('claw'),
    targetJp: document.getElementById('target-jp'),
    targetRomaji: document.getElementById('target-romaji'),
    waveBanner: document.getElementById('wave-banner'),
    countdown: document.getElementById('countdown'),
    imeHint: document.getElementById('ime-hint'),
    tip: document.getElementById('wave-tip'),
    startOverlay: document.getElementById('start-overlay'),
    btnStart: document.getElementById('btn-start'),
    practice: document.getElementById('practice-toggle'),
    btnMute: document.getElementById('btn-mute'),
    btnRestart: document.getElementById('btn-restart'),
    overlay: document.getElementById('result-overlay'),
    rTitle: document.getElementById('result-title'),
    rScore: document.getElementById('result-score'),
    rBonus: document.getElementById('result-bonus'),
    rKills: document.getElementById('result-kills'),
    rWave: document.getElementById('result-wave'),
    rTime: document.getElementById('result-time'),
    rCombo: document.getElementById('result-combo'),
    rAccuracy: document.getElementById('result-accuracy'),
    rSpeed: document.getElementById('result-speed'),
    rPerfect: document.getElementById('result-perfect'),
    rGrades: document.getElementById('result-grades'),
    rOverall: document.getElementById('result-overall'),
    rBest: document.getElementById('result-best'),
    rPractice: document.getElementById('result-practice'),
    mobileInput: document.getElementById('mobile-input')
  };

  // ゲーム状態
  var phase = 'idle'; // idle / countdown / playing / over
  var rafId = null;
  var lastFrame = 0;
  var pausedAt = 0;   // タブ非表示で止めた時刻(0=止めていない)
  var adGuardIv = null; // プレイ中、下端のアンカー広告がステージに被っていないか定期チェックする

  var entities = [];       // 画面上のゾンビ・ボス・投げ物・アイテム・クイズの選択肢
  var target = null;       // ロックオン中の個体
  var nextSpawnAt = 0;
  var bossCount = 0;       // 何体目のボスか(種類のローテーションに使う)
  var waveState = 'fight'; // fight(戦闘中)/ advance(クリア後の前進中)
  var spawnedInWave = 0;   // このウェーブで出したゾンビ数
  var bossSpawned = false; // ボス戦でボスを出したか
  var advanceUntil = 0;
  var killsAtWaveStart = 0;
  var nextThrowAt = 0;
  var nextItemAt = 0;
  var slowUntil = 0;       // ウェーブ切替の一瞬スロー
  var spawnedTotal = 0;

  var score = 0;
  var combo = 0;
  var maxCombo = 0;
  var kills = 0;
  var wave = 1;
  var hp = MAX_HP;
  var correctKeys = 0;
  var missKeys = 0;
  var perfects = 0;
  var rank = 3;            // 腕前ランク 1〜8(倒すと上がり、被弾で下がる)
  var killsForRank = 0;
  var playStart = 0;
  var practice = false;
  var imeHinted = false;

  // ステージの実寸(投影に使う)。ResizeObserver で追従
  var W = 640, H = 360;
  function measure() {
    W = els.field.clientWidth || W;
    H = els.field.clientHeight || H;
  }
  if (window.ResizeObserver) {
    new ResizeObserver(measure).observe(els.field);
  } else {
    window.addEventListener('resize', measure);
  }
  measure();

  // ============================================
  // サウンド(共通基盤 js/game-audio.js)
  // ============================================
  var muted = GameAudio.init('zombie-typing');
  GameAudio.setBgm('../../assets/bgm/seikan.mp3', 0.1);

  function ensureAudio() { GameAudio.unlock(); }

  var sounds = {
    // 打鍵音は typeShot / typeKill(打鍵クリック主体)。以前の shot / shotFinal は
    // 低域主体でノートPCのスピーカーではほとんど聞こえなかった
    shot: function (power) { GameAudio.sfx('typeShot', { power: power }); },
    shotFinal: function (boss) { GameAudio.sfx('typeKill', { boss: boss }); },
    miss: function () { GameAudio.sfx('dryfire'); },
    groan: function () { GameAudio.sfx('groan'); },
    damage: function () { GameAudio.sfx('claw'); },
    wave: function () { GameAudio.sfx('combo'); },
    bossIn: function () { GameAudio.sfx('bossIn'); },
    heal: function () { GameAudio.sfx('heal'); },
    count: function () { GameAudio.sfx('count'); },
    go: function () { GameAudio.sfx('go'); },
    gameover: function () { GameAudio.sfx('lost'); }
  };

  function updateMuteButton() {
    els.btnMute.textContent = muted ? '🔇' : '🔊';
    els.btnMute.setAttribute('aria-label', muted ? 'サウンドをオンにする' : 'サウンドをオフにする');
  }

  // ============================================
  // ベストスコア(採点方式を変えたのでキーを改めた)
  // ============================================
  var BEST_KEY = 'zombieTypingBest2';
  function getBest() { return parseInt(localStorage.getItem(BEST_KEY) || '0', 10); }
  function setBest(v) { localStorage.setItem(BEST_KEY, String(v)); }

  // ============================================
  // 投影(擬似3D)
  // z: 1=最奥 → 0=目の前 / lane: -1〜1
  // 奥ほど小さく・地平線寄り・中央寄り。手前に来るほど急に大きくなる
  // ============================================
  var HORIZON = 0.34;
  var BODY_H = 190;   // 最近接(scale 1)のときのゾンビの高さ px(CSS の .zombie の高さと合わせる)

  function project(z, lane) {
    var p = 1 - Math.max(0, Math.min(1, z));
    return {
      x: W * (0.5 + lane * (0.14 + p * 0.36)),
      y: H * (HORIZON + Math.pow(p, 1.35) * (0.98 - HORIZON)),
      s: 0.16 + Math.pow(p, 1.6) * 0.84,
      p: p
    };
  }

  function depthClass(p) {
    return p < 0.2 ? 0 : p < 0.4 ? 1 : p < 0.6 ? 2 : p < 0.8 ? 3 : 4;
  }

  // ============================================
  // 出現
  // ============================================
  var LANES = [-1, -0.5, 0, 0.5, 1];

  function pickLane() {
    var busy = {};
    entities.forEach(function (e) {
      if (e.alive && e.z > 0.65) busy[String(Math.round(e.lane * 2) / 2)] = true;
    });
    var free = LANES.filter(function (l) { return !busy[String(l)]; });
    var pool = free.length ? free : LANES;
    return pool[Math.floor(Math.random() * pool.length)] + (Math.random() - 0.5) * 0.12;
  }

  function pickType() {
    var r = Math.random();
    var c = combatIndex(wave);
    var runnerP = 0.15 + Math.min(0.1 + c * 0.05, 0.35) + (rank - 3) * 0.02;
    var tankP = c >= 3 ? Math.min(0.15 + (rank - 3) * 0.02, 0.3) : 0;
    if (r < tankP) return 'tank';
    if (r < tankP + runnerP) return 'runner';
    return 'normal';
  }

  // 画面上の個体と「最初の1打」がかぶりにくいお題を選ぶ(ロックオンの取り違え防止)
  function usedFirstKeys() {
    var used = {};
    entities.forEach(function (e) {
      if (!e.alive || !e.state) return;
      'abcdefghijklmnopqrstuvwxyz-'.split('').forEach(function (c) {
        if (canAccept(e.state, c)) used[c] = true;
      });
    });
    return used;
  }

  function collides(word, used) {
    var st = createWordState(word.k);
    return 'abcdefghijklmnopqrstuvwxyz-'.split('').some(function (c) {
      return canAccept(st, c) && used[c];
    });
  }

  // 最近出したお題は避ける。プレイをまたいでも同じ語がめったに出ないよう localStorage に持つ
  // (プールごとに件数の6割まで覚え、古いものから忘れる)
  var RECENT_KEY = 'zombieTypingRecent';
  var recent = {};
  try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '{}') || {}; } catch (e) { recent = {}; }

  function rememberWord(pool, w) {
    var list = WORDS[pool];
    var r = recent[pool] || (recent[pool] = []);
    var idx = r.indexOf(w.k);
    if (idx !== -1) r.splice(idx, 1);
    r.push(w.k);
    var keep = Math.floor(list.length * 0.6);
    while (r.length > keep) r.shift();
    try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent)); } catch (e) { /* 無視 */ }
  }

  // ウェーブが進むにつれて、使うプールと語の長さを広げる。
  // いきなり長文が来ると初心者が離脱するので、序盤は短い語だけにする
  function poolFor(typeName, w) {
    var c = combatIndex(w);
    if (typeName === 'runner') return 'short';
    if (typeName === 'normal') return c <= 2 ? 'short' : 'medium';
    if (typeName === 'tank') return c <= 4 ? 'medium' : 'long';
    return TYPES[typeName].pool;
  }

  // 5→8 と一気に伸ばすと wave4 で急に打ち切れなくなるので、中間の段を足してある
  function maxKanaFor(w) {
    var c = combatIndex(w);
    if (c <= 2) return 5;
    if (c <= 3) return 7;
    if (c <= 5) return 9;
    if (c <= 7) return 12;
    if (c <= 9) return 15;
    return 99;
  }

  function pickWord(pool, exclude, maxLen) {
    var list = WORDS[pool];
    var used = usedFirstKeys();
    var r = recent[pool] || [];
    var fresh = list.filter(function (w) {
      return r.indexOf(w.k) === -1 && !(exclude && exclude.indexOf(w) !== -1)
        && (!maxLen || w.k.length <= maxLen);
    });
    if (!fresh.length && maxLen) {
      // 長さ制限で全滅したら、制限内の語を「最近出た」を無視して拾い直す。
      // ただし exclude(同じボスで既に使った文)だけは必ず守る。
      // ここで exclude を落としていたため、1体のボスが同じ文を2度3度出すことがあった
      fresh = list.filter(function (w) {
        return w.k.length <= maxLen && !(exclude && exclude.indexOf(w) !== -1);
      });
    }
    if (!fresh.length) fresh = list.filter(function (w) { return !(exclude && exclude.indexOf(w) !== -1); });
    if (!fresh.length) fresh = list;
    // 先頭キーが画面上の敵とかぶらない語を優先し、無ければかぶってもよい
    var ok = fresh.filter(function (w) { return !collides(w, used); });
    var pick = rnd(ok.length ? ok : fresh);
    rememberWord(pool, pick);
    return pick;
  }

  function makeEl(typeName, kind) {
    var el = document.createElement('div');
    el.className = 'zombie zombie-' + typeName + ' kind-' + kind;
    el.innerHTML = '<div class="z-shadow"></div><div class="z-body">' + artFor(typeName) + '</div>';
    els.field.appendChild(el);
    return el;
  }

  function makeBubble(cls) {
    var b = document.createElement('div');
    b.className = 'z-bubble ' + (cls || '');
    b.innerHTML = '<span class="z-jp"></span><span class="z-romaji"></span>';
    els.field.appendChild(b);
    return b;
  }

  function speedFor(typeName) {
    var t = TYPES[typeName];
    var waveMult = Math.min(1 + (combatIndex(wave) - 1) * 0.07, 1.9);
    if (typeName === 'throw' || typeName === 'item') waveMult = 1;
    return (1 / TRAVEL_SECONDS) * t.speed * waveMult * (practice ? 0.6 : 1);
  }

  function makeEntity(kind, typeName, word, opts) {
    opts = opts || {};
    var e = {
      kind: kind,                // zombie / boss / throw / item / choice
      type: typeName,
      word: word,
      state: word && word.k ? createWordState(word.k) : null,
      z: opts.z !== undefined ? opts.z : 0.85 + Math.random() * 0.1,
      lane: opts.lane !== undefined ? opts.lane : pickLane(),
      speed: opts.speed !== undefined ? opts.speed : speedFor(typeName),
      alive: true,
      wordMisses: 0,
      lockedAt: 0,
      keysDone: 0,
      lastDepth: -1,
      lastZ: -1,
      wasNear: false,
      el: null,
      bubble: null
    };
    e.el = makeEl(typeName, kind);
    e.bubble = makeBubble(kind === 'choice' ? 'choice' : '');
    entities.push(e);
    renderWord(e);
    renderEntity(e);
    return e;
  }

  function spawnZombie() {
    // 待ち伏せ: ウェーブ3以降、たまに道ばたの近くから突然出てくる(短い語・すぐ目の前)
    var ambushP = isHardPostBossWave(wave) ? 0.04 : (isPostBossWave(wave) ? 0.07 : 0.13);
    if (combatIndex(wave) >= 4 && spawnedTotal >= 2 && Math.random() < ambushP) return spawnAmbush();
    var typeName = pickType();
    var word = pickWord(poolFor(typeName, wave), null, maxKanaFor(wave));
    spawnedTotal++;
    var e = makeEntity('zombie', typeName, word);
    if (Math.random() < 0.35) sounds.groan();
    return e;
  }

  function spawnAmbush() {
    spawnedTotal++;
    var side = Math.random() < 0.5 ? -1 : 1;
    var e = makeEntity('zombie', 'runner', pickWord('short'), { z: 0.34 + Math.random() * 0.08, lane: side * (0.9 + Math.random() * 0.15) });
    e.speed = speedFor('normal') * 0.9;
    e.el.classList.add('rise');
    e.ambush = true;
    sounds.groan();
    return e;
  }

  // 物陰の投げ手: 道ばたに立ったまま近づいてこないが、一定間隔で骨を投げてくる。倒すまで続く
  var nextLurkerAt = 0;
  function spawnLurker() {
    var side = Math.random() < 0.5 ? -1 : 1;
    var e = makeEntity('zombie', 'lurker', pickWord('medium'), { z: 0.5, lane: side * 1.28, speed: 0 });
    e.lurker = true;
    e.nextThrow = performance.now() + 2500;
    e.leaveAt = performance.now() + 40000;
    e.el.classList.add('rise');
    sounds.groan();
    return e;
  }

  // でかゾンビが1文字の投げ物(骨)を放つ。1キーで撃ち落とす。当たると1ダメージ
  var VOWELS = [{ d: 'あ', k: 'あ' }, { d: 'い', k: 'い' }, { d: 'う', k: 'う' }, { d: 'え', k: 'え' }, { d: 'お', k: 'お' }];
  function spawnThrow(from) {
    var used = usedFirstKeys();
    var cands = VOWELS.filter(function (w) { return !collides(w, used); });
    if (!cands.length) return;
    var w = cands[Math.floor(Math.random() * cands.length)];
    // 道ばたの投げ手からは道の中央へ向けて飛ぶ
    var lane = from.lurker ? from.lane * 0.35 : from.lane;
    makeEntity('throw', 'throw', w, { z: from.z - 0.02, lane: lane });
    from.el.classList.remove('throwing');
    void from.el.offsetWidth;
    from.el.classList.add('throwing');
  }

  function spawnItem() {
    var w = pickWord('item');
    makeEntity('item', 'item', w, { z: 0.92 });
    if (!tipsShown.item) {
      tipsShown.item = true;
      showTip('<b>救急箱</b>だ! 打ち切ると HP が1回復する', 5000);
    }
  }

  // ============================================
  // ボス(4種をローテーション)
  //   long : 長文3フレーズ / quiz: 3択クイズ / acc: ミスすると即ダメージ / swift: 語が入れ替わる小型
  // ============================================
  // ============================================
  // クイズの自動生成
  // ============================================
  // 手書きの quiz プールだけだと、1体のボスが4問しか使わないうえ問題文の種類も
  // 限られるので「同じ問題ばかり」になっていた。カテゴリ分類と算数から作れば
  // 組み合わせが数万通りになり、同じ問題に当たることはほぼなくなる。
  //
  // 3択は「先頭キーが重ならないこと」が必須(重なるとロックオンが取り違える)。
  // 「い」= i/yi、「か」= ka/ca、「し」= si/shi/ci のように見た目が違っても
  // 衝突するので、必ず canAccept で実際に確かめてから採用する。
  var QUIZ_MAX_KANA = 8;   // 選択肢のかな長上限(吹き出しに収まる範囲)

  function firstKeysOf(w) {
    var st = createWordState(w.k);
    return 'abcdefghijklmnopqrstuvwxyz-'.split('').filter(function (c) { return canAccept(st, c); });
  }

  function choicesUsable(list) {
    for (var i = 0; i < list.length; i++) {
      if (!list[i] || list[i].k.length > QUIZ_MAX_KANA) return false;
      var ki = firstKeysOf(list[i]);
      if (!ki.length) return false;
      for (var j = i + 1; j < list.length; j++) {
        if (list[i].d === list[j].d) return false;
        var kj = firstKeysOf(list[j]);
        if (ki.some(function (c) { return kj.indexOf(c) >= 0; })) return false;
      }
    }
    return true;
  }

  // 数を読みがなにする。九九や2桁の足し算引き算で使う範囲(0〜99)だけ対応
  var NUM_KANA = ['ぜろ', 'いち', 'に', 'さん', 'よん', 'ご', 'ろく', 'なな', 'はち', 'きゅう'];
  var TENS_KANA = ['', 'じゅう', 'にじゅう', 'さんじゅう', 'よんじゅう', 'ごじゅう',
                   'ろくじゅう', 'ななじゅう', 'はちじゅう', 'きゅうじゅう'];
  function numKana(n) {
    if (n < 10) return NUM_KANA[n];
    if (n > 99) return null;
    var t = Math.floor(n / 10), o = n % 10;
    return TENS_KANA[t] + (o ? NUM_KANA[o] : '');
  }

  function numChoice(n) {
    var k = numKana(n);
    if (!k || k.length > QUIZ_MAX_KANA) return null;
    return { d: String(n), k: k };
  }

  // 算数(かけ算・たし算・ひき算)。答えの読みが長すぎるものは自然に弾かれる
  function makeMathQuiz() {
    for (var tries = 0; tries < 40; tries++) {
      var kind = Math.floor(Math.random() * 3);
      var a, b, ans, q;
      if (kind === 0) {
        a = 2 + Math.floor(Math.random() * 8);
        b = 2 + Math.floor(Math.random() * 8);
        ans = a * b;
        q = a + ' × ' + b + ' は?';
      } else if (kind === 1) {
        a = 2 + Math.floor(Math.random() * 60);
        b = 2 + Math.floor(Math.random() * 35);
        ans = a + b;
        q = a + ' + ' + b + ' は?';
      } else {
        a = 20 + Math.floor(Math.random() * 70);
        b = 2 + Math.floor(Math.random() * 18);
        ans = a - b;
        q = a + ' − ' + b + ' は?';
      }
      if (ans < 0 || ans > 99) continue;
      var correct = numChoice(ans);
      if (!correct) continue;
      // ダミーは正解の近くから。近すぎる数は読みが似るので少し散らす
      var pool = [];
      [-11, -9, -7, -4, -3, -2, 2, 3, 4, 7, 9, 11].forEach(function (d) {
        var v = ans + d;
        if (v >= 0 && v <= 99) { var c = numChoice(v); if (c) pool.push(c); }
      });
      shuffle(pool);
      for (var i = 0; i < pool.length; i++) {
        for (var j = i + 1; j < pool.length; j++) {
          var set = [correct, pool[i], pool[j]];
          if (choicesUsable(set)) return { q: q, a: set, ans: 0 };
        }
      }
    }
    return null;
  }

  // カテゴリ分類。「このうち◯◯はどれ?」と「仲間はずれはどれ?」の2種類
  function makeCategoryQuiz() {
    var cats = WORDS.quizGen && WORDS.quizGen.categories;
    if (!cats || cats.length < 3) return null;
    var odd = Math.random() < 0.35;
    for (var tries = 0; tries < 40; tries++) {
      var ci = Math.floor(Math.random() * cats.length);
      var cat = cats[ci];
      // ダミーは「別グループ」からしか取らない。たとえば うに/さけ は
      // 海の生きものでも食べものでもあるので、混ぜると答えが2つになってしまう
      var others = cats.filter(function (c, i) {
        if (i === ci) return false;
        var gm = cat.g || [], go = c.g || [];
        return !gm.some(function (x) { return go.indexOf(x) >= 0; });
      });
      if (others.length < 2) return null;
      shuffle(others);
      if (odd) {
        // 同じカテゴリを2つ+よそから1つ。よそ者を答えさせる
        var same = shuffle(cat.words.slice()).slice(0, 2);
        var alien = rnd(others[0].words);
        var set = [alien, same[0], same[1]];
        if (choicesUsable(set)) {
          return { q: cat.name + 'じゃないのはどれ?', a: set, ans: 0 };
        }
      } else {
        var correct = rnd(cat.words);
        var d1 = rnd(others[0].words);
        var d2 = rnd(others[1].words);
        var set2 = [correct, d1, d2];
        if (choicesUsable(set2)) {
          var q = Math.random() < 0.5
            ? 'このうち' + cat.name + 'はどれ?'
            : cat.name + 'の仲間はどれ?';
          return { q: q, a: set2, ans: 0 };
        }
      }
    }
    return null;
  }

  // 手書きの問題と自動生成を混ぜて、ボス1体ぶんの出題を作る
  function buildQuizQuestions(count) {
    var out = [];
    var rq = recent.quiz || [];
    var handmade = WORDS.quiz.filter(function (q) { return rq.indexOf(q.q) === -1; });
    if (handmade.length < 2) handmade = WORDS.quiz.slice();
    shuffle(handmade);
    var guard = 0;
    while (out.length < count && guard++ < 60) {
      var q = null;
      var r = Math.random();
      if (r < 0.4) q = makeCategoryQuiz();
      else if (r < 0.75) q = makeMathQuiz();
      else q = handmade.pop() || null;
      if (!q) continue;
      if (out.some(function (x) { return x.q === q.q; })) continue;
      out.push(q);
    }
    // 生成に失敗した分は手書きで埋める(万一のフォールバック)
    while (out.length < count && WORDS.quiz.length) {
      var f = rnd(WORDS.quiz);
      if (!out.some(function (x) { return x.q === f.q; })) out.push(f);
    }
    out.forEach(function (q) { rememberWord('quiz', { k: q.q }); });
    return out;
  }

  var BOSS_ORDER = ['long', 'quiz', 'acc', 'swift'];

  function shuffle(a) {
    for (var i = a.length - 1; i > 0; i--) {
      var j = Math.floor(Math.random() * (i + 1));
      var t = a[i]; a[i] = a[j]; a[j] = t;
    }
    return a;
  }

  var BOSS_KINDS = {
    long: {
      label: 'でかボス',
      tip: '長い文を<b>3回</b>打ち切ると撃破。1文打つごとにロックオンが外れるので、あわてず打ち直そう',
      setup: function (b) {
        b.phrases = [];
        var ex = [];
        var lim = wave <= 3 ? 14 : 99;
        for (var i = 0; i < 3; i++) { var w = pickWord('boss', ex, lim); ex.push(w); b.phrases.push(w); }
        b.pi = 0;
        b.word = b.phrases[0];
        b.state = createWordState(b.word.k);
      },
      // 1フレーズ打ち切ったとき。true=まだ続く / false=撃破
      next: function (b) {
        if (b.pi >= b.phrases.length - 1) return false;
        b.keysDone += doneKeys(b.state);
        b.pi++;
        b.word = b.phrases[b.pi];
        b.state = createWordState(b.word.k);
        return true;
      }
    },
    quiz: {
      label: 'クイズボス',
      tip: '出た<b>問題の答え</b>を3つの中から選んで打つ。正解だと大ダメージ!',
      setup: function (b) {
        b.state = null;              // ボス本体は打てない。選択肢(choice)を打つ
        b.dmg = 0;
        b.qIndex = 0;
        b.questions = buildQuizQuestions(5);
        b.choices = [];
        BOSS_KINDS.quiz.ask(b);
      },
      ask: function (b) {
        BOSS_KINDS.quiz.clearChoices(b);
        var q = b.questions[b.qIndex % b.questions.length];
        b.word = { d: q.q, k: '' };
        renderWord(b);
        var order = shuffle([0, 1, 2]);
        order.forEach(function (ai, slot) {
          var w = q.a[ai];
          var c = makeEntity('choice', 'normal', w, { z: b.z, lane: b.lane + (slot - 1) * 0.55, speed: 0 });
          c.el.style.display = 'none';       // 見た目はボスに任せ、吹き出しだけ出す
          c.parent = b;
          c.slot = slot;
          c.correct = ai === q.ans;
          b.choices.push(c);
        });
      },
      clearChoices: function (b) {
        b.choices.forEach(function (c) { removeEntity(c, 0); });
        b.choices = [];
      },
      // 選択肢を1つ打ち切った。true=まだ続く / false=撃破
      answered: function (b, c) {
        // 不正解ではダメージを与えない。以前は 1/6 入っていて、
        // でたらめに打っても6回で倒せてしまいクイズの意味がなかった
        if (c.correct) {
          b.dmg += 0.5;
          score += 200;
          popAt(b, '正解! +200', 'pop-boss');
        } else {
          combo = 0;
          popAt(b, 'はずれ… もう一問!', 'pop-miss');
        }
        if (b.dmg >= 0.99) return false;
        b.qIndex++;
        BOSS_KINDS.quiz.ask(b);
        return true;
      },
      onRemove: function (b) { BOSS_KINDS.quiz.clearChoices(b); },
      onTick: function (b) {
        b.choices.forEach(function (c) { c.z = b.z; c.lane = b.lane + (c.slot - 1) * 0.55; });
      }
    },
    acc: {
      label: '正確さボス',
      tip: '<b>打ちまちがえるとダメージ</b>! 速さより正確さ。落ち着いて1文字ずつ',
      setup: function (b) {
        b.phrases = [];
        var ex = [];
        for (var i = 0; i < 3; i++) { var w = pickWord('medium', ex, maxKanaFor(wave)); ex.push(w); b.phrases.push(w); }
        b.pi = 0;
        b.word = b.phrases[0];
        b.state = createWordState(b.word.k);
        b.speed *= 0.85;
      },
      next: function (b) { return BOSS_KINDS.long.next(b); },
      // 打っている最中のミスは即ダメージ
      onMiss: function (b) {
        takeDamage(1, b);
        popAt(b, 'ミスは許さん!', 'pop-miss');
      }
    },
    swift: {
      label: 'すばやいボス',
      tip: 'ことばが<b>3秒で入れ替わる</b>。短いので見えたらすぐ打ち切ろう',
      setup: function (b) {
        b.done = 0;
        b.swapAt = performance.now() + 3000;
        b.phase0 = Math.random() * Math.PI * 2;
      },
      next: function (b) {
        b.done++;
        if (b.done >= 3) return false;
        b.keysDone += doneKeys(b.state);
        b.word = pickWord('short', [b.word]);
        b.state = createWordState(b.word.k);
        b.swapAt = performance.now() + 3000;
        return true;
      },
      onTick: function (b, now) {
        b.lane = Math.sin(now / 900 + b.phase0) * 0.8;
        if (now >= b.swapAt) {
          b.word = pickWord('short', [b.word]);
          b.state = createWordState(b.word.k);
          b.wordMisses = 0;
          b.swapAt = now + 3000;
          renderWord(b);
          if (target === b) { target.lockedAt = 0; renderTargetBoard(); }
          flinch(b);
        }
      }
    }
  };

  function spawnBoss() {
    var kindName = BOSS_ORDER[bossCount % BOSS_ORDER.length];
    bossCount++;
    var typeName = kindName === 'swift' ? 'swift' : 'boss';
    var word = kindName === 'swift' ? pickWord('short') : null;
    var b = makeEntity('boss', typeName, word, { z: 0.95, lane: 0 });
    b.bossKind = kindName;
    b.el.classList.add('boss-' + kindName);
    BOSS_KINDS[kindName].setup(b);
    renderWord(b);
    renderEntity(b);
    sounds.bossIn();
    showBanner(BOSS_KINDS[kindName].label + ' 登場!', 'boss');
    if (BOSS_KINDS[kindName].tip) {
      var tip = BOSS_KINDS[kindName].tip;
      setTimeout(function () { showTip(tip, 6500); }, 900);
    }
    return b;
  }

  // ============================================
  // 描画
  // ============================================
  function renderWord(e) {
    if (!e.bubble) return;
    var jp = e.bubble.querySelector('.z-jp');
    var rom = e.bubble.querySelector('.z-romaji');
    jp.textContent = e.word ? e.word.d : '';
    e.bubbleW = 0;   // 文言が変わったので大きさを測り直す
    e.bubbleH = 0;
    if (e.state) {
      var parts = romajiParts(e.state);
      rom.innerHTML = '<span class="r-done">' + parts[0] + '</span><span class="r-rest">' + parts[1] + '</span>';
      rom.style.display = '';
    } else {
      rom.innerHTML = '';
      rom.style.display = 'none';
    }
  }

  function renderEntity(e) {
    var pr = project(e.z, e.lane);
    var scale = pr.s * TYPES[e.type].size;
    e.el.style.transform = 'translate3d(' + pr.x.toFixed(1) + 'px,' + pr.y.toFixed(1) + 'px,0) translate(-50%,-100%) scale(' + scale.toFixed(3) + ')';
    var d = depthClass(pr.p);
    if (d !== e.lastDepth) {
      e.el.setAttribute('data-depth', d);
      e.lastDepth = d;
    }
    var zi = Math.round(pr.p * 100);
    if (zi !== e.lastZ) {
      e.el.style.zIndex = String(10 + zi);
      e.lastZ = zi;
      if (e.bubble) e.bubble.style.zIndex = String(120 + zi);
    }
    if (e.bubble) {
      var by = pr.y - BODY_H * scale - 6;
      // 遠くても文字が読めるよう下限を高めに取る(奥行きは本体の大きさで表現できている)
      var bs = e.kind === 'choice' ? 0.98 : Math.max(0.96, Math.min(1.18, 0.92 + pr.p * 0.3));
      var cx = pr.x;
      if (e.kind !== 'choice') {
        // 遠い敵ほど道が狭まって吹き出しが重なるので、レーン方向に少しだけ広げて置く
        // (敵本体は動かさないので、どの敵のことばかは見て分かる)
        cx = W * 0.5 + (pr.x - W * 0.5) * (1 + (1 - pr.p) * 0.35);
      }
      if (e.kind === 'choice' && e.parent) {
        // 選択肢はボスの胸元に、遠くても重ならないよう px 単位で横に並べる
        var pp = project(e.parent.z, e.parent.lane);
        var ps = pp.s * TYPES[e.parent.type].size;
        by = pp.y - BODY_H * ps * 0.5;
        cx = pp.x + (e.slot - 1) * (100 + 60 * pp.p);
      }
      // 端のレーンで吹き出しがステージ外に切れないよう、幅ぶんだけ内側に寄せる
      var half = (e.bubbleW || (e.bubbleW = e.bubble.offsetWidth || 80)) * bs / 2 + 4;
      var bx = Math.max(half, Math.min(W - half, cx));
      // 背の高いボスなどで吹き出しが画面上(HUDの裏)へ飛び出さないよう下限を設ける
      var bh = (e.bubbleH || (e.bubbleH = e.bubble.offsetHeight || 40)) * bs;
      by = Math.max(bh + 40, by);
      e.bubble.style.transform = 'translate3d(' + bx.toFixed(1) + 'px,' + by.toFixed(1) + 'px,0) translate(-50%,-100%) scale(' + bs.toFixed(3) + ')';
      var near = e.z < 0.25 && e.kind !== 'item' && e.kind !== 'choice';
      if (near !== e.wasNear) {
        e.bubble.classList.toggle('near', near);
        e.wasNear = near;
      }
    }
  }

  function setTarget(e) {
    if (target && target.el) {
      target.el.classList.remove('targeted');
      if (target.bubble) target.bubble.classList.remove('targeted');
      if (target.kind === 'choice' && target.parent) target.parent.el.classList.remove('targeted');
    }
    target = e;
    if (e) {
      e.el.classList.add('targeted');
      if (e.bubble) e.bubble.classList.add('targeted');
      if (e.kind === 'choice' && e.parent) e.parent.el.classList.add('targeted');
      els.reticle.classList.add('locked');
    } else {
      els.reticle.classList.remove('locked');
      els.reticle.style.transform = '';
    }
    renderTargetBoard();
  }

  function renderTargetBoard() {
    if (!target || !target.state) {
      els.targetJp.textContent = phase === 'playing' ? 'ローマ字を打ってロックオン!(Esc でねらい直し)' : '';
      els.targetRomaji.textContent = '';
      els.targetJp.classList.add('hint');
      return;
    }
    els.targetJp.classList.remove('hint');
    var parts = romajiParts(target.state);
    var label = target.kind === 'boss' && target.phrases ? ' (' + (target.pi + 1) + '/' + target.phrases.length + ')' : '';
    els.targetJp.textContent = target.word.d + label;
    els.targetRomaji.innerHTML =
      '<span class="r-done">' + parts[0] + '</span>' +
      '<span class="r-rest">' + parts[1] + '</span>';
  }

  function moveReticle() {
    if (!target) return;
    var pr = project(target.z, target.lane);
    var scale = pr.s * TYPES[target.type].size;
    var y = pr.y - BODY_H * scale * (target.kind === 'throw' || target.kind === 'item' ? 0.5 : 0.62);
    els.reticle.style.transform = 'translate3d(' + (pr.x - W / 2).toFixed(1) + 'px,' + (y - H * 0.78).toFixed(1) + 'px,0)';
  }

  // 発砲エフェクト(マズルフラッシュ+着弾スパーク)
  var sparkBudget = 0;
  function fireEffect(e) {
    els.muzzle.classList.remove('flash');
    void els.muzzle.offsetWidth;
    els.muzzle.classList.add('flash');
    if (sparkBudget > 6) return;   // 連打で溜まりすぎないように
    sparkBudget++;
    var pr = project(e.z, e.lane);
    var scale = pr.s * TYPES[e.type].size;
    var sp = document.createElement('span');
    sp.className = 'spark';
    sp.style.transform = 'translate3d(' + (pr.x + (Math.random() - 0.5) * 30 * scale).toFixed(1) + 'px,' + (pr.y - BODY_H * scale * (0.4 + Math.random() * 0.4)).toFixed(1) + 'px,0)';
    els.field.appendChild(sp);
    setTimeout(function () { if (sp.parentNode) sp.parentNode.removeChild(sp); sparkBudget--; }, 220);
  }

  // 1発で倒れないボスは、当てるたびに後ろへのけぞって下がる(手応えを出す)
  function knockback(e, amount) {
    e.z = Math.min(1, e.z + amount);
    if (e.charging && e.z > 0) {
      e.charging = false;
      e.el.classList.remove('charging');
      if (e.bubble) e.bubble.classList.remove('charging');
    }
    e.el.classList.remove('knock');
    void e.el.offsetWidth;
    e.el.classList.add('knock');
    renderEntity(e);
  }

  function flinch(e) {
    var art = e.el.querySelector('.z-art');
    if (!art) return;
    art.classList.remove('flinch');
    void art.getBoundingClientRect();
    art.classList.add('flinch');
  }

  function popAt(e, text, cls) {
    var pr = project(e.z, e.lane);
    var scale = pr.s * TYPES[e.type].size;
    var el = document.createElement('div');
    el.className = 'score-pop ' + (cls || '');
    el.textContent = text;
    el.style.transform = 'translate3d(' + pr.x.toFixed(1) + 'px,' + (pr.y - BODY_H * scale * 0.9).toFixed(1) + 'px,0) translate(-50%,-100%)';
    els.field.appendChild(el);
    setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 1000);
  }

  // 撃破の破片(骨・星)。血は出さない
  function burst(e) {
    var pr = project(e.z, e.lane);
    var scale = pr.s * TYPES[e.type].size;
    var chars = ['★', '✦', '●', '✦', '★', '●'];
    for (var i = 0; i < 6; i++) {
      var p = document.createElement('span');
      p.className = 'z-part';
      p.textContent = chars[i];
      var ang = (i / 6) * Math.PI * 2 + Math.random() * 0.6;
      p.style.setProperty('--dx', (Math.cos(ang) * (40 + Math.random() * 40) * Math.max(0.5, scale)).toFixed(0) + 'px');
      p.style.setProperty('--dy', (Math.sin(ang) * (30 + Math.random() * 40) * Math.max(0.5, scale) - 30).toFixed(0) + 'px');
      p.style.left = pr.x.toFixed(1) + 'px';
      p.style.top = (pr.y - BODY_H * scale * 0.5).toFixed(1) + 'px';
      p.style.fontSize = (10 + 12 * scale).toFixed(0) + 'px';
      els.field.appendChild(p);
      (function (node) { setTimeout(function () { if (node.parentNode) node.parentNode.removeChild(node); }, 650); })(p);
    }
  }

  function removeEntity(e, delay) {
    e.alive = false;
    if (target === e) setTarget(null);
    if (e.kind === 'boss' && BOSS_KINDS[e.bossKind] && BOSS_KINDS[e.bossKind].onRemove) BOSS_KINDS[e.bossKind].onRemove(e);
    var el = e.el, b = e.bubble;
    if (b && b.parentNode) b.parentNode.removeChild(b);
    e.bubble = null;
    if (!delay) {
      if (el.parentNode) el.parentNode.removeChild(el);
    } else {
      setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, delay);
    }
  }

  // ============================================
  // 撃破・被弾・スコア
  // ============================================
  function comboMult() { return 1 + Math.min(combo, 50) * 0.03; }

  // 1語(1フレーズ)を打ち切ったときの得点。速さ・ノーミス・至近距離で伸びる
  function scoreWord(e, keys, now) {
    var elapsed = Math.max(0.25, (now - (e.lockedAt || now)) / 1000);
    var kps = keys / elapsed;
    var speedMult = kps >= 5 ? 1.5 : kps >= 3.5 ? 1.25 : 1;
    var noMiss = e.wordMisses === 0;
    var near = e.z < 0.2;
    var mult = TYPES[e.type].mult || 1;
    var gained = Math.round(keys * 10 * mult * comboMult() * speedMult * (noMiss ? 1.2 : 1) * (near ? 1.1 : 1));
    var label = speedMult === 1.5 ? 'PERFECT!' : speedMult === 1.25 ? 'FAST!' : (noMiss ? '精密' : '');
    if (near) label = (label ? label + ' ' : '') + 'ギリギリ!';
    if (speedMult === 1.5) perfects++;
    return { gained: gained, label: label };
  }

  function bumpCombo() {
    combo++;
    if (combo > maxCombo) maxCombo = combo;
  }

  function killEntity(e, now) {
    // ボスは直前のフレーズ分だけ(前のフレーズは打ち切った時点で加点済み)
    var keys = e.state ? doneKeys(e.state) : 0;
    var res = keys ? scoreWord(e, keys, now) : { gained: 0, label: '' };
    score += res.gained;
    if (e.kind === 'boss') {
      var bonus = wave * 300;
      score += bonus;
      popAt(e, (res.gained ? '+' + res.gained + '  ' : '') + 'ボス撃破 +' + bonus, 'pop-boss');
      sounds.shotFinal(true);
    } else {
      popAt(e, '+' + res.gained + (res.label ? '  ' + res.label : ''), res.label.indexOf('PERFECT') === 0 ? 'pop-perfect' : '');
      sounds.shotFinal(false);
    }
    bumpCombo();
    kills++;
    killsForRank++;
    if (killsForRank >= 5) { killsForRank = 0; rank = Math.min(8, rank + 1); }
    burst(e);
    e.el.classList.add('dead');
    removeEntity(e, 500);
    updateHud();
    trackExtra();
  }

  function shootDownThrow(e) {
    score += 30;
    bumpCombo();
    popAt(e, '+30 撃墜!', '');
    sounds.shotFinal(false);
    burst(e);
    e.el.classList.add('dead');
    removeEntity(e, 300);
    updateHud();
  }

  function pickupItem(e) {
    score += 50;
    if (hp < MAX_HP) hp++;
    popAt(e, 'HP回復 +50', 'pop-heal');
    sounds.heal();
    e.el.classList.add('dead');
    removeEntity(e, 300);
    updateHud();
  }

  function takeDamage(amount) {
    hp -= amount;
    if (hp < 0) hp = 0;
    combo = 0;
    rank = Math.max(1, rank - 1);
    killsForRank = 0;
    sounds.damage();
    els.stage.classList.remove('hit');
    void els.stage.offsetWidth;
    els.stage.classList.add('hit');
    els.claw.classList.remove('show');
    void els.claw.offsetWidth;
    els.claw.classList.add('show');
    updateHud();
    trackExtra();
    if (hp <= 0) endGame();
  }

  function entityAttacks(e) {
    if (e.kind === 'item') { removeEntity(e, 0); return; }
    if (e.kind === 'choice') return;
    if (e.kind === 'boss') {
      // ボスは殴ったら下がって仕切り直し(倒すまでボス戦は終わらない)。
      // ダメージ量は雑魚と同じ1に統一してある(以前は2で「いきなり2つ減った」と分かりにくかった)
      takeDamage(1);
      e.z = 0.7;
      flinch(e);
      if (target === e) setTarget(null);
      return;
    }
    e.el.classList.add('attacking');
    removeEntity(e, 450);
    takeDamage(1);
  }

  // ============================================
  // 表示(HUD)
  // ============================================
  function updateHud() {
    els.score.textContent = score.toLocaleString();
    els.best.textContent = getBest().toLocaleString();
    els.hudWave.textContent = wave + (phase === 'playing' && !isBossWave(wave) && waveState === 'fight'
      ? ' (' + Math.min(kills - killsAtWaveStart, waveQuota(wave)) + '/' + waveQuota(wave) + ')' : (phase === 'playing' && isBossWave(wave) ? ' BOSS' : ''));
    els.hudCombo.textContent = combo;
    els.hudKills.textContent = kills;
    var h = '';
    for (var i = 0; i < MAX_HP; i++) h += '<span class="hp-heart' + (i < hp ? '' : ' off') + '"></span>';
    els.hudHp.innerHTML = h;
    var danger = phase === 'playing' && hp <= 2;
    els.stage.classList.toggle('danger', danger);
    if (danger) GameAudio.startLoop('heartbeat', 900); else GameAudio.stopLoop('heartbeat');
  }

  function showBanner(text, cls) {
    els.waveBanner.textContent = text;
    els.waveBanner.className = 'wave-banner ' + (cls || '');
    void els.waveBanner.offsetWidth;
    els.waveBanner.classList.add('show');
  }

  function syncIdleUi() {
    var idle = phase === 'idle';
    els.startOverlay.classList.toggle('active', idle);
    els.stage.classList.toggle('idle', idle);
  }

  function trackExtra() {
    if (!window.PlayTrack) return;
    var sec = Math.max(1, (performance.now() - playStart) / 1000);
    PlayTrack.setExtra({
      started: 1, wave: wave, kills: kills, hp: hp, rank: rank,
      kps: Math.round(correctKeys / sec * 10) / 10,
      acc: accuracy(), practice: practice ? 1 : 0
    });
  }

  // ============================================
  // 自分の移動感(レール式)
  // 路面のしるしと道ばたの墓石・枯れ木を奥から手前へ流し、ウェーブ切替時は小走りで「前進」する。
  // カメラの揺れは CSS(.zombie-field の cam-bob)
  // ============================================
  var WALK_SPEED = 0.05;     // ふだんの歩き(z/秒)
  var DASH_SPEED = 0.55;     // 前進中
  var dashUntil = 0;
  var props = [];

  var PROP_ART = {
    grave: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M8 38V16a12 12 0 0 1 24 0v22z" fill="#3D3547" stroke="#1E1926" stroke-width="1.5"/><path d="M14 22h12M20 18v14" stroke="#241E2C" stroke-width="2"/></svg>',
    cross: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M17 4h6v34h-6zM8 12h24v6H8z" fill="#4A4254" stroke="#1E1926" stroke-width="1"/></svg>',
    tree: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M20 40V14M20 20l-8-10M20 24l9-12M20 16l-5-12M20 12l6-9" stroke="#0E0A16" stroke-width="3" stroke-linecap="round" fill="none"/></svg>',
    // 校庭
    // 鉄棒(支柱2本+横棒1本)。以前は横棒が2本あって鳥居に見えてしまっていた
    bar: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M8 40V16M32 40V16M6 16h28" stroke="#8A8A72" stroke-width="3.5" stroke-linecap="round" fill="none"/><circle cx="8" cy="16" r="2.4" fill="#6E6E5C"/><circle cx="32" cy="16" r="2.4" fill="#6E6E5C"/></svg>',
    bench: '<svg viewBox="0 0 40 40" aria-hidden="true"><rect x="4" y="20" width="32" height="5" rx="2" fill="#6B5A3E"/><rect x="4" y="27" width="32" height="4" rx="2" fill="#5A4A32"/><path d="M9 25v13M31 25v13" stroke="#3E3423" stroke-width="3"/></svg>',
    // 商店街
    lamp: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M20 40V10" stroke="#4A4450" stroke-width="3"/><path d="M20 10h10" stroke="#4A4450" stroke-width="3"/><ellipse cx="31" cy="14" rx="5" ry="4" fill="#FFDD8A"/><ellipse cx="31" cy="16" rx="10" ry="7" fill="#FFDD8A" opacity="0.22"/></svg>',
    sign: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M14 40V16M26 40V16" stroke="#3E3646" stroke-width="3"/><rect x="6" y="6" width="28" height="14" rx="2" fill="#B8443C" stroke="#2A1418" stroke-width="1.5"/><rect x="10" y="10" width="20" height="2.5" rx="1" fill="#F7E3B0" opacity="0.7"/><rect x="10" y="15" width="13" height="2.5" rx="1" fill="#F7E3B0" opacity="0.5"/></svg>',
    post: '<svg viewBox="0 0 40 40" aria-hidden="true"><rect x="12" y="10" width="16" height="30" rx="3" fill="#B03A3A" stroke="#2A1418" stroke-width="1.5"/><rect x="15" y="16" width="10" height="3" rx="1.5" fill="#2A1418"/><path d="M12 10h16l-2-5H14z" fill="#8E2E2E"/></svg>',
    // 森
    stump: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M10 40V24a10 6 0 0 1 20 0v16z" fill="#4A3A24" stroke="#241B10" stroke-width="1.5"/><ellipse cx="20" cy="24" rx="10" ry="5" fill="#6B5436"/><ellipse cx="20" cy="24" rx="5" ry="2.4" fill="#4A3A24"/></svg>',
    rock: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M6 40l6-16 10-6 12 8 4 14z" fill="#4A4A46" stroke="#22221F" stroke-width="1.5"/><path d="M14 30l8-8 8 6" stroke="#5E5E58" stroke-width="2" fill="none"/></svg>',
    // 廃墟
    pillar: '<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M12 40V12l4-6 8 4 2 30z" fill="#514B4B" stroke="#241F1F" stroke-width="1.5"/><path d="M13 26h13M13 33h13" stroke="#393434" stroke-width="2"/></svg>',
    drum: '<svg viewBox="0 0 40 40" aria-hidden="true"><rect x="11" y="12" width="18" height="28" rx="3" fill="#5C6B4A" stroke="#232A1C" stroke-width="1.5"/><ellipse cx="20" cy="12" rx="9" ry="3" fill="#6E7F58"/><path d="M11 22h18M11 30h18" stroke="#3B4630" stroke-width="2"/></svg>',
    mark: ''
  };

  // 道ばたの小物は場所(シーン)ごとに変える
  var propKinds = ['grave', 'cross', 'tree'];

  function initProps(kinds) {
    if (kinds) propKinds = kinds;
    props.forEach(function (p) { if (p.el.parentNode) p.el.parentNode.removeChild(p.el); });
    props = [];
    var i;
    for (i = 0; i < 6; i++) addProp('mark', i / 6, 0);
    for (i = 0; i < 4; i++) addProp(rnd(propKinds), (i + 0.3) / 4, -1.35 - Math.random() * 0.15);
    for (i = 0; i < 4; i++) addProp(rnd(propKinds), (i + 0.7) / 4, 1.35 + Math.random() * 0.15);
  }

  function addProp(kind, z, lane) {
    var el = document.createElement('div');
    el.className = 'prop prop-' + kind;
    el.innerHTML = PROP_ART[kind];
    els.field.appendChild(el);
    props.push({ kind: kind, z: z, lane: lane, el: el });
  }

  function updateProps(dt, now) {
    var v = now < dashUntil ? DASH_SPEED : WALK_SPEED;
    props.forEach(function (p) {
      p.z -= v * dt;
      if (p.z <= 0) {
        p.z += 1;
        if (p.kind !== 'mark') {
          p.kind = rnd(propKinds);
          p.el.className = 'prop prop-' + p.kind;
          p.el.innerHTML = PROP_ART[p.kind];
        }
      }
      var pr = project(p.z, p.lane);
      var sc = pr.s * (p.kind === 'mark' ? 1 : 0.9);
      p.el.style.transform = 'translate3d(' + pr.x.toFixed(1) + 'px,' + pr.y.toFixed(1) + 'px,0) translate(-50%,-100%) scale(' + sc.toFixed(3) + ')';
      p.el.style.opacity = p.kind === 'mark' ? (0.15 + pr.p * 0.35).toFixed(2) : (0.5 + pr.p * 0.5).toFixed(2);
      p.el.style.zIndex = String(2 + Math.round(pr.p * 5));
    });
    els.stage.classList.toggle('dashing', now < dashUntil);
    return v;
  }

  // 新しい要素が初めて出るウェーブで、短い説明を出す(1回だけ)
  // キーはウェーブ番号。新しい要素が実際に出はじめるウェーブに合わせてある
  // (ボス戦を挟むぶん、combatIndex とはズレる点に注意: c3=wave4, c4=wave5, c5=wave7, c6=wave8)
  var WAVE_TIPS = {
    1: 'ことばを<b>ローマ字で打つ</b>と撃てる。最初の1打でねらいが決まる(Escでやり直し)',
    2: '<b>走るゾンビ</b>は速い!短いことばの敵から片づけよう',
    4: '<b>でかゾンビ</b>が混じりはじめる。文が長いので早めにねらおう',
    5: '道ばたから<b>いきなり出てくる</b>ゾンビに注意!',
    7: '<b>🦴 骨が飛んでくる!</b> 頭の上の1文字(あ・い・う・え・お)を打てば撃ち落とせる',
    8: '<b>物陰の投げ手</b>が出る。近づいてこないので、まず前の敵から倒そう'
  };
  var tipsShown = {};

  function showTip(html, ms) {
    els.tip.innerHTML = html;
    els.tip.classList.add('show');
    clearTimeout(showTip._t);
    showTip._t = setTimeout(function () { els.tip.classList.remove('show'); }, ms || 5000);
  }

  function startWave(now) {
    waveState = 'fight';
    spawnedInWave = 0;
    killsAtWaveStart = kills;
    bossSpawned = false;
    nextSpawnAt = now + 400;
    nextThrowAt = now + 12000;
    var sc = applyScene(wave);
    if (!isBossWave(wave)) {
      showBanner('WAVE ' + wave + '  ' + (sc ? sc.name + '  ' : '') + waveQuota(wave) + '体', '');
      if (WAVE_TIPS[wave] && !tipsShown[wave]) {
        tipsShown[wave] = true;
        setTimeout(function () { showTip(WAVE_TIPS[wave], 6000); }, 900);
      }
    }
    updateHud();
    trackExtra();
  }

  function clearWave(now) {
    var bonus = wave * 150;
    score += bonus;
    sounds.wave();
    showBanner((isBossWave(wave) ? 'ボス撃破!' : 'WAVE ' + wave + ' クリア!') + '  +' + bonus + '  前進!', 'clear');
    waveState = 'advance';
    advanceUntil = now + 1900;
    dashUntil = now + 1700;
    slowUntil = now + 600;
    setTarget(null);
    updateHud();
    trackExtra();
  }

  // ============================================
  // ステージ(背景)— ウェーブごとに舞台が変わる
  // 背景SVGは毎回 JS で組み立てて差し替える。道と地平線の位置は project() と合わせてあるので
  // 場所が変わっても遠近感は崩れない。ボス戦は必ず「血の月」
  // ============================================
  var STARS = '<g fill="#F4F0FF" opacity="0.7">' +
    '<circle cx="60" cy="30" r="1.2"/><circle cx="140" cy="18" r="0.9"/><circle cx="220" cy="44" r="1.1"/>' +
    '<circle cx="380" cy="22" r="1"/><circle cx="470" cy="50" r="1.3"/><circle cx="560" cy="16" r="0.9"/>' +
    '<circle cx="600" cy="62" r="1"/><circle cx="300" cy="70" r="0.8"/></g>';

  var SCENES = [
    {
      name: '墓場',
      sky: ['#0B0620', '#241541', '#4A2E5C'],
      ground: ['#2E2A2E', '#151116'],
      road: ['#4E4652', '#2A2430'],
      fog: '#8E7BA8',
      moon: { x: 520, y: 46, r: 26, color: '#F9E79F' },
      hills: 'M0 122c40-14 70-10 110-22 40-12 60 6 100 2 40-4 60-18 110-16 50 2 70 14 120 10 50-4 80-16 110-8 30 8 60 22 90 22v12H0z',
      hillColor: '#171026',
      far: '<g fill="#0E0A16"><path d="M250 150l3-14 2 8 3-11 2 15z"/><path d="M392 150l-3-14-2 8-3-11-2 15z"/></g>',
      props: ['grave', 'cross', 'tree']
    },
    {
      name: '夜の校庭',
      sky: ['#04122B', '#0E2A4E', '#1E4A6B'],
      ground: ['#3A3A2E', '#1A1A14'],
      road: ['#5A5647', '#312E26'],
      fog: '#7FA8C8',
      moon: { x: 545, y: 38, r: 22, color: '#EAF6FF' },
      hills: 'M0 122h60v-46h150v46h40v-30h120v30h70v-52h130v52h70v12H0z',
      hillColor: '#0B1D33',
      // 校舎の窓と鉄棒
      far: '<g fill="#F4D06F" opacity="0.75">' +
        '<rect x="80" y="86" width="9" height="9"/><rect x="98" y="86" width="9" height="9"/><rect x="128" y="86" width="9" height="9"/>' +
        '<rect x="160" y="86" width="9" height="9"/><rect x="404" y="78" width="9" height="9"/><rect x="424" y="78" width="9" height="9"/>' +
        '<rect x="466" y="78" width="9" height="9"/></g>' +
        '<g stroke="#0B1D33" stroke-width="3" fill="none"><path d="M232 150v-16h44v16M232 138h44"/></g>',
      props: ['bar', 'bench', 'tree']
    },
    {
      name: '商店街',
      sky: ['#1A0A16', '#3A1524', '#6B2E33'],
      ground: ['#33292A', '#171114'],
      road: ['#584C4E', '#2D2528'],
      fog: '#C89B8E',
      moon: { x: 470, y: 34, r: 20, color: '#FFE8B0' },
      hills: 'M0 122h70v-40h90v40h30v-58h110v58h60v-34h100v34h60v-46h120v46H0z',
      hillColor: '#241018',
      // 商店の灯りと提灯
      far: '<g fill="#FFC66D" opacity="0.8">' +
        '<rect x="86" y="92" width="12" height="8"/><rect x="112" y="92" width="12" height="8"/>' +
        '<rect x="204" y="76" width="10" height="10"/><rect x="228" y="76" width="10" height="10"/><rect x="252" y="76" width="10" height="10"/>' +
        '<rect x="392" y="98" width="12" height="8"/><rect x="430" y="98" width="12" height="8"/></g>' +
        '<g fill="#E74C3C" opacity="0.85"><ellipse cx="180" cy="132" rx="7" ry="9"/><ellipse cx="462" cy="132" rx="7" ry="9"/></g>',
      props: ['lamp', 'sign', 'post']
    },
    {
      name: '深い森',
      sky: ['#04160F', '#0C2A1C', '#1B4030'],
      ground: ['#25301F', '#101609'],
      road: ['#464E38', '#252A1E'],
      fog: '#9FD3B0',
      moon: { x: 330, y: 30, r: 18, color: '#DFF5E1' },
      hills: 'M0 122l40-40 22 26 26-44 30 40 34-52 28 44 30-36 34 48 30-40 32 34 30-46 34 42 30-30 30 40 26-36 30 34 34-42 30 44 30-26 30 40v12H0z',
      hillColor: '#08170E',
      far: '<g stroke="#0A2015" stroke-width="4" fill="none" opacity="0.9">' +
        '<path d="M120 150v-30M120 132l-10-12M120 138l12-14"/><path d="M520 150v-34M520 130l-12-14M520 138l10-12"/></g>',
      props: ['tree', 'stump', 'rock']
    },
    {
      name: '血の月',       // ボス戦専用
      sky: ['#180207', '#3B0710', '#6B121C'],
      ground: ['#2B1B1B', '#140B0C'],
      road: ['#57414A', '#2C2026'],
      fog: '#C86B72',
      moon: { x: 320, y: 44, r: 40, color: '#E74C3C' },
      hills: 'M0 122l60-24 40 30 50-52 46 44 60-30 44 26 60-46 50 40 60-24 50 32 60-26v30H0z',
      hillColor: '#12060A',
      far: '<g fill="#0B0406"><path d="M120 150v-40h30v40zM134 108h8v-14h-8z"/><path d="M498 150v-46h34v46zM508 100h10v-16h-10z"/></g>',
      props: ['pillar', 'drum', 'grave']
    }
  ];

  // 3の倍数(ボス戦)は血の月。それ以外は順に巡る
  function sceneFor(w) {
    if (isBossWave(w)) return 4;
    var bossesBefore = w >= FIRST_BOSS_WAVE ? Math.floor((w - FIRST_BOSS_WAVE) / BOSS_EVERY) + 1 : 0;
    return ((w - 1 - bossesBefore) % 4 + 4) % 4;
  }

  function sceneSvg(sc) {
    return '<defs>' +
      '<linearGradient id="zt-sky" x1="0" y1="0" x2="0" y2="1">' +
      '<stop offset="0" stop-color="' + sc.sky[0] + '"/><stop offset="0.7" stop-color="' + sc.sky[1] + '"/><stop offset="1" stop-color="' + sc.sky[2] + '"/></linearGradient>' +
      '<linearGradient id="zt-ground" x1="0" y1="0" x2="0" y2="1">' +
      '<stop offset="0" stop-color="' + sc.ground[0] + '"/><stop offset="1" stop-color="' + sc.ground[1] + '"/></linearGradient>' +
      '<linearGradient id="zt-road" x1="0" y1="0" x2="0" y2="1">' +
      '<stop offset="0" stop-color="' + sc.road[0] + '"/><stop offset="1" stop-color="' + sc.road[1] + '"/></linearGradient>' +
      '<linearGradient id="zt-fog" x1="0" y1="0" x2="0" y2="1">' +
      '<stop offset="0" stop-color="' + sc.fog + '" stop-opacity="0"/><stop offset="0.5" stop-color="' + sc.fog + '" stop-opacity="0.45"/>' +
      '<stop offset="1" stop-color="' + sc.fog + '" stop-opacity="0"/></linearGradient></defs>' +
      '<rect width="640" height="122" fill="url(#zt-sky)"/>' + STARS +
      '<circle cx="' + sc.moon.x + '" cy="' + sc.moon.y + '" r="' + sc.moon.r + '" fill="' + sc.moon.color + '"/>' +
      '<circle cx="' + (sc.moon.x - 8) + '" cy="' + (sc.moon.y - 6) + '" r="' + (sc.moon.r * 0.19) + '" fill="#000" opacity="0.08"/>' +
      '<circle cx="' + (sc.moon.x + 10) + '" cy="' + (sc.moon.y + 10) + '" r="' + (sc.moon.r * 0.12) + '" fill="#000" opacity="0.08"/>' +
      '<path d="' + sc.hills + '" fill="' + sc.hillColor + '"/>' +
      '<rect y="122" width="640" height="238" fill="url(#zt-ground)"/>' +
      '<polygon points="308,122 332,122 610,360 30,360" fill="url(#zt-road)"/>' +
      '<rect x="0" y="100" width="640" height="60" fill="url(#zt-fog)"/>' +
      sc.far;
  }

  var currentScene = -1;
  function applyScene(w) {
    var idx = sceneFor(w);
    if (idx === currentScene) return;
    currentScene = idx;
    var sc = SCENES[idx];
    els.stageBg.innerHTML = sceneSvg(sc);
    els.stageBg.classList.remove('scene-in');
    void els.stageBg.offsetWidth;
    els.stageBg.classList.add('scene-in');
    initProps(sc.props);
    return sc;
  }

  // ============================================
  // メインループ
  // ============================================
  function countAlive() {
    return entities.filter(function (e) { return e.alive && (e.kind === 'zombie' || e.kind === 'boss'); }).length;
  }

  function bossAlive() {
    return entities.some(function (e) { return e.alive && e.kind === 'boss'; });
  }

  function loop(now) {
    if (phase !== 'playing' || pausedAt) return;
    var dt = Math.min((now - lastFrame) / 1000, 0.1);
    lastFrame = now;
    if (now < slowUntil) dt *= 0.4;

    // 自分の移動(路面・道ばたの小物を流す)。前進中は敵との距離も少し早く縮まる
    var walkV = updateProps(dt, now);
    var extraZ = now < dashUntil ? walkV * 0.35 * dt : 0;

    // ---- ウェーブ進行(討伐制)----
    if (waveState === 'advance') {
      if (now >= advanceUntil) { wave++; startWave(now); }
    } else if (isBossWave(wave)) {
      // ボス戦: ボスだけ。倒したらクリア(他の敵・投げ手・待ち伏せは出さない)
      if (!bossSpawned) { bossSpawned = true; spawnBoss(); }
      else if (!bossAlive()) clearWave(now);
    } else {
      var quota = waveQuota(wave);
      var cIdx = combatIndex(wave);
      // スポーン(同時出現数は1回の戦闘ごとに少しずつ増やす。腕前ランクで間隔を伸縮)。
      // 以前は wave2 の 2体から wave4 で 6体へ跳ねており、死亡がこのウェーブに集中していた
      var maxAlive = Math.min(2 + Math.floor(cIdx * 0.7), 7);
      if (isHardPostBossWave(wave)) maxAlive = Math.max(2, maxAlive - 2);
      else if (isPostBossWave(wave)) maxAlive = Math.max(2, maxAlive - 1);
      if (spawnedInWave < quota && now >= nextSpawnAt && countAlive() < maxAlive) {
        spawnZombie();
        spawnedInWave++;
        var interval = Math.max(2600 - cIdx * 180, 1100) * (1.2 - rank * 0.05) * (practice ? 1.4 : 1);
        if (isHardPostBossWave(wave)) interval += 900;
        else if (isPostBossWave(wave)) interval += 400;
        nextSpawnAt = now + interval * (0.8 + Math.random() * 0.4);
      }
      // 物陰の投げ手(戦闘7回目=ウェーブ10以降・同時に1体・倒すまで骨を投げ続ける。ノルマを出し切ったら出さない)
      // 以前は戦闘6回目=wave8で解禁しており、wave7で解禁済みの投げ物と同じウェーブに
      // 新ギミックが重なっていた(実測でwave8の死亡率が突出)。1段後ろへずらして分散させる
      if (cIdx >= 7 && spawnedInWave < quota - 2 && now >= nextLurkerAt && !entities.some(function (e) { return e.alive && e.lurker; })) {
        spawnLurker();
        nextLurkerAt = now + 25000;
      }
      // ノルマを出し切って全滅させたらクリア。投げ手だけ残っていたら去らせる
      if (spawnedInWave >= quota) {
        var rest = entities.filter(function (e) { return e.alive && (e.kind === 'zombie' || e.kind === 'boss'); });
        if (rest.length && rest.every(function (e) { return e.lurker; })) {
          rest.forEach(function (e) { e.leaveAt = Math.min(e.leaveAt, now); });
        } else if (!rest.length) {
          clearWave(now);
        }
      }
    }

    // 投げ物(wave5以降。腕前ランクが高いと少しだけ頻度が上がる)
    if (combatIndex(wave) >= 5 && !isBossWave(wave) && now >= nextThrowAt) {
      var throwers = entities.filter(function (e) { return e.alive && e.kind === 'zombie' && e.type === 'tank' && e.z > 0.3 && e.z < 0.9; });
      if (throwers.length) {
        spawnThrow(throwers[Math.floor(Math.random() * throwers.length)]);
        // ボス2体目以降の直後ウェーブ(初登場のwave7を含む)は同時出現も増えているので、
        // 投げ物の間隔をさらに伸ばして負荷が重ならないようにする
        nextThrowAt = now + (isHardPostBossWave(wave) ? (rank >= 6 ? 10000 : 13000) : (rank >= 6 ? 7000 : 9000));
      } else {
        nextThrowAt = now + 1500;
      }
    }

    // 回復アイテム(HPが減っているときだけ、たまに)
    if (hp <= 2 && wave >= 3 && now >= nextItemAt && !entities.some(function (e) { return e.alive && e.kind === 'item'; })) {
      spawnItem();
      nextItemAt = now + 20000;
    }

    // 移動と到達判定
    for (var i = entities.length - 1; i >= 0; i--) {
      var e = entities[i];
      if (!e.alive) {
        if (!e.el.parentNode) entities.splice(i, 1);
        continue;
      }
      if (e.kind === 'boss' && BOSS_KINDS[e.bossKind].onTick) BOSS_KINDS[e.bossKind].onTick(e, now);
      if (e.lurker) {
        // 投げ手は動かず、間隔をおいて骨を投げる。長く放っておくと去る
        if (now >= e.nextThrow) { spawnThrow(e); e.nextThrow = now + (rank >= 6 ? 6000 : 8000); }
        if (now >= e.leaveAt) { e.el.classList.add('sink'); removeEntity(e, 500); continue; }
        renderEntity(e);
        continue;
      }
      if (e.kind !== 'choice' && !e.charging) e.z -= (e.speed + (e.kind === 'zombie' || e.kind === 'boss' ? extraZ : 0)) * dt;
      if (e.z <= 0) {
        // 目の前まで来たらいったん立ち止まって振りかぶる。この間に打ち切ればノーダメージ
        if (e.kind === 'throw' || e.kind === 'item') {
          entityAttacks(e);
          if (phase !== 'playing') return;
          continue;
        }
        if (!e.charging) {
          e.z = 0;
          e.charging = true;
          e.attackAt = now + (e.type === 'runner' ? 1000 : e.kind === 'boss' ? 2000 : 1500);
          e.el.classList.add('charging');
          if (e.bubble) e.bubble.classList.add('charging');
          sounds.groan();
        } else if (now >= e.attackAt) {
          entityAttacks(e);
          if (phase !== 'playing') return;
          continue;
        }
      }
      renderEntity(e);
      if (e.kind === 'zombie' && e.z < 0.3 && !e.groaned) { e.groaned = true; if (Math.random() < 0.5) sounds.groan(); }
    }
    moveReticle();

    rafId = requestAnimationFrame(loop);
  }

  // ============================================
  // 入力処理
  // ============================================
  function handleChar(ch, now) {
    if (phase !== 'playing') return;
    now = now || performance.now();
    ch = ch.toLowerCase();
    if (!/^[a-z\-]$/.test(ch)) return;

    // 投げ物は1キーで完結するので、ロックオン中でも割り込んで撃ち落とせる
    var thrown = entities.filter(function (e) { return e.alive && e.kind === 'throw' && canAccept(e.state, ch); });
    if (thrown.length && !(target && target.alive && target.state && canAccept(target.state, ch))) {
      var t = thrown.sort(function (a, b) { return a.z - b.z; })[0];
      acceptKey(t.state, ch);
      correctKeys++;
      fireEffect(t);
      shootDownThrow(t);
      return;
    }

    // ロックオン中はその個体だけが対象
    if (target && target.alive && target.state) {
      if (acceptKey(target.state, ch)) {
        onCorrectKey(target, now);
      } else {
        registerMiss(target);
      }
      return;
    }

    // 未ロックオン: このキーで打ち始められる一番近い個体を狙う
    var candidates = entities.filter(function (e) {
      return e.alive && e.state && canAccept(e.state, ch);
    });
    if (candidates.length === 0) {
      registerMiss(null);
      return;
    }
    candidates.sort(function (a, b) { return a.z - b.z; });
    var e = candidates[0];
    acceptKey(e.state, ch);
    e.lockedAt = now;
    setTarget(e);
    onCorrectKey(e, now);
  }

  function onCorrectKey(e, now) {
    correctKeys++;
    if (!e.lockedAt) e.lockedAt = now;
    var power = Math.min(1, (1 - e.z) * 0.6 + (combo >= 50 ? 0.4 : combo >= 25 ? 0.25 : combo >= 10 ? 0.12 : 0));
    sounds.shot(power);
    fireEffect(e);
    var hit = e.kind === 'choice' && e.parent ? e.parent : e;
    flinch(hit);
    // ボスは1打ごとにもわずかに押し戻される(倒すまで時間がかかるぶん手応えを出す)
    if (hit.kind === 'boss' && !isWordComplete(e.state)) knockback(hit, 0.006);
    renderWord(e);
    renderTargetBoard();
    if (isWordComplete(e.state)) {
      if (e.kind === 'item') {
        pickupItem(e);
      } else if (e.kind === 'choice') {
        var boss = e.parent;
        var res = scoreWord(e, doneKeys(e.state), now);
        setTarget(null);
        if (e.correct) {
          bumpCombo();
          score += Math.round(res.gained * 3);
        }
        if (!BOSS_KINDS.quiz.answered(boss, e)) {
          killEntity(boss, now);
        } else if (e.correct) {
          knockback(boss, 0.2);
          flinch(boss);
          sounds.shotFinal(false);
        } else {
          // はずれ: ボスは効いていない。下がらず、逆に一歩詰め寄る
          boss.z = Math.max(0.08, boss.z - 0.05);
          renderEntity(boss);
          sounds.miss();
        }
      } else if (e.kind === 'boss' && BOSS_KINDS[e.bossKind].next) {
        var phraseKeys = doneKeys(e.state);
        if (BOSS_KINDS[e.bossKind].next(e)) {
          // フレーズを1本打ち切った: 得点+後退。
          // ロックオンはここで一度外す(3本打ち切るまで固定だと、その間に走るゾンビへ手が出せず殴られ放題になる)。
          // 次のフレーズは打ち始めれば改めてロックオンできる
          var r = scoreWord(e, phraseKeys, now);
          bumpCombo();
          score += r.gained;
          popAt(e, '+' + r.gained + (r.label ? '  ' + r.label : ''), 'pop-boss');
          knockback(e, 0.16);
          e.wordMisses = 0;
          e.lockedAt = 0;
          sounds.shotFinal(false);
          flinch(e);
          renderWord(e);
          setTarget(null);
        } else {
          killEntity(e, now);
        }
      } else {
        killEntity(e, now);
      }
    }
    updateHud();
  }

  function registerMiss(e) {
    missKeys++;
    combo = 0;
    if (e) e.wordMisses++;
    sounds.miss();   // 画面は揺らさない(揺れ+音だと被弾と区別がつかない)
    if (e && e.kind === 'boss' && BOSS_KINDS[e.bossKind].onMiss) BOSS_KINDS[e.bossKind].onMiss(e);
    updateHud();
  }

  // 日本語入力(IME)がONだと e.key が "Process" になって何も起きない。
  // 学校PCはIME ONが既定になりがちなので、物理キー(e.code)から英字に読み替えて遊べるようにする
  function charFromEvent(e) {
    if (e.key && e.key.length === 1) return e.key;
    if (e.key === 'Process' || e.isComposing || e.keyCode === 229) {
      var m = /^Key([A-Z])$/.exec(e.code || '');
      if (m) { showImeHint(); return m[1].toLowerCase(); }
      if (e.code === 'Minus') { showImeHint(); return '-'; }
    }
    return null;
  }

  function showImeHint() {
    if (imeHinted) return;
    imeHinted = true;
    els.imeHint.classList.add('show');
    setTimeout(function () { els.imeHint.classList.remove('show'); }, 5000);
  }

  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    // 文字を入力する欄(ニックネーム欄など)にフォーカスがあるときはゲームに流さない。
    // チェックボックスやボタンは対象外(「ゆっくり練習」を触った直後でも Enter で始められるように)
    var tag = e.target && e.target.tagName;
    var textLike = tag === 'TEXTAREA' || tag === 'SELECT' ||
      (tag === 'INPUT' && !/^(checkbox|radio|button|submit)$/i.test(e.target.type || ''));
    if (textLike && e.target !== els.mobileInput) return;

    if (phase === 'playing') {
      if (e.repeat) { e.preventDefault(); return; }
      // Esc でロックオンを外してねらい直す(打った分の進みは残るので、戻ってくれば続きから打てる)
      if (e.key === 'Escape') {
        e.preventDefault();
        if (target) { setTarget(null); }
        return;
      }
      var ch = charFromEvent(e);
      if (ch !== null) {
        e.preventDefault();
        handleChar(ch, performance.now());
      }
      return;
    }
    if (e.key === 'Enter' && phase === 'idle' && !els.overlay.classList.contains('active')) {
      e.preventDefault();
      startGame();
    }
    if (e.key === 'Escape' && els.overlay.classList.contains('active')) {
      closeResult();
    }
  });

  // スマホ・タブレット: ステージタップでソフトキーボードを出す
  els.stage.addEventListener('click', function () {
    if (phase === 'playing') els.mobileInput.focus({ preventScroll: true });
  });
  els.mobileInput.addEventListener('input', function (e) {
    // IME変換中に value を消すと入力が壊れる。確定後のかなも打鍵ではないので数えない
    // (keydown 側で物理キーから拾っているので、ここで通すと同じ打鍵を二重に処理してしまう)
    if (e && e.isComposing) return;
    var v = els.mobileInput.value;
    els.mobileInput.value = '';
    for (var i = 0; i < v.length; i++) {
      var c = v.charAt(i);
      if (c.charCodeAt(0) < 0x80) handleChar(c, performance.now());
    }
  });

  // タブを離れている間はゲームを止める(戻ったときに敵が一気に来ないように時計をずらす)
  document.addEventListener('visibilitychange', function () {
    if (phase !== 'playing') return;
    if (document.hidden) {
      if (!pausedAt) pausedAt = performance.now();
      if (rafId) cancelAnimationFrame(rafId);
    } else if (pausedAt) {
      var gap = performance.now() - pausedAt;
      pausedAt = 0;
      playStart += gap;
      nextSpawnAt += gap;
      nextThrowAt += gap;
      nextItemAt += gap;
      entities.forEach(function (e) {
        if (e.swapAt) e.swapAt += gap;
        if (e.nextThrow) e.nextThrow += gap;
        if (e.leaveAt) e.leaveAt += gap;
        if (e.attackAt) e.attackAt += gap;
      });
      nextLurkerAt += gap;
      advanceUntil += gap;
      dashUntil += gap;
      lastFrame = performance.now();
      rafId = requestAnimationFrame(loop);
    }
  });

  // ============================================
  // ゲーム進行
  // ============================================
  function startGame() {
    ensureAudio();
    practice = !!(els.practice && els.practice.checked);
    phase = 'countdown';
    syncIdleUi();

    score = 0; combo = 0; maxCombo = 0; kills = 0; wave = 1; hp = MAX_HP;
    correctKeys = 0; missKeys = 0; perfects = 0; rank = 3; killsForRank = 0;
    bossCount = 0; spawnedTotal = 0; waveState = 'fight'; spawnedInWave = 0; bossSpawned = false; advanceUntil = 0; killsAtWaveStart = 0;
    nextThrowAt = 0; nextItemAt = 0; slowUntil = 0; pausedAt = 0;
    entities.forEach(function (e) {
      if (e.bubble && e.bubble.parentNode) e.bubble.parentNode.removeChild(e.bubble);
      if (e.el.parentNode) e.el.parentNode.removeChild(e.el);
    });
    entities = [];
    GameAudio.stopAllLoops();
    setTarget(null);
    nextLurkerAt = 0;
    dashUntil = 0;
    currentScene = -1;
    tipsShown = {};
    els.tip.classList.remove('show');

    els.overlay.classList.remove('active');
    els.targetJp.textContent = '';
    els.targetRomaji.textContent = '';
    updateHud();
    measure();
    if (window.PlayTrack) PlayTrack.setExtra({ started: 1, practice: practice ? 1 : 0 });

    var count = 3;
    els.countdown.textContent = count;
    els.countdown.classList.add('active');
    sounds.count();
    var iv = setInterval(function () {
      count--;
      if (count > 0) {
        els.countdown.textContent = count;
        sounds.count();
      } else {
        clearInterval(iv);
        els.countdown.textContent = 'ゾンビが来るぞ…!';
        sounds.go();
        setTimeout(function () {
          els.countdown.classList.remove('active');
          beginPlay();
        }, 600);
      }
    }, 700);
  }

  function beginPlay() {
    phase = 'playing';
    GameAudio.startBgm();
    playStart = performance.now();
    lastFrame = playStart;
    nextItemAt = playStart + 15000;
    startWave(playStart);
    renderTargetBoard();
    rafId = requestAnimationFrame(loop);
    // 下端のアンカー広告(50〜129px・端末で高さが変わる)はページ読み込みより遅れて
    // 差し込まれたり、プレイ中に途中から出ることがある。共通の GameLayout で、
    // ステージが隠れていたら押し上げる(既にスクロール済みなら何もしない判定は内蔵済み)
    if (window.GameLayout) {
      GameLayout.ensureVisible(els.stage, true);
      if (adGuardIv) clearInterval(adGuardIv);
      adGuardIv = setInterval(function () {
        if (phase === 'playing') GameLayout.ensureVisible(els.stage, true);
      }, 2500);
    }
  }

  function accuracy() {
    var total = correctKeys + missKeys;
    if (total === 0) return 100;
    return Math.round((correctKeys / total) * 100);
  }

  function titleFor(s) {
    for (var i = 0; i < TITLES.length; i++) {
      if (s >= TITLES[i].min) return TITLES[i].name;
    }
    return TITLES[TITLES.length - 1].name;
  }

  function formatDuration(ms) {
    var sec = Math.floor(ms / 1000);
    var m = Math.floor(sec / 60);
    var s = sec % 60;
    return m > 0 ? m + '分' + s + '秒' : s + '秒';
  }

  // 項目別ランク(S/A/B/C/D)。本家の章末評価にならって速さ・正確さ・生存・コンボを別々に見せる
  var GRADES = ['D', 'C', 'B', 'A', 'S'];
  function gradeIndex(v, th) {  // th: [C, B, A, S] のしきい値
    var g = 0;
    for (var i = 0; i < th.length; i++) if (v >= th[i]) g = i + 1;
    return g;
  }

  function gradeCell(label, g) {
    return '<div class="grade-cell"><span class="grade-label">' + label + '</span><span class="grade-value grade-' + GRADES[g] + '">' + GRADES[g] + '</span></div>';
  }

  function closeResult() {
    els.overlay.classList.remove('active');
    syncIdleUi();
  }

  function endGame() {
    phase = 'over';
    GameAudio.stopBgm(1.2);
    GameAudio.stopAllLoops();
    if (adGuardIv) { clearInterval(adGuardIv); adGuardIv = null; }
    if (rafId) cancelAnimationFrame(rafId);
    sounds.gameover();
    els.stage.classList.remove('danger');

    var survived = performance.now() - playStart;
    var acc = accuracy();
    var kps = correctKeys / Math.max(1, survived / 1000);
    var baseScore = score;
    // 章末の正確さボーナス: 80%未満は0、100%で1,200
    var accBonus = acc < 80 ? 0 : Math.round((acc - 80) / 20 * 1200);
    score += accBonus;

    setTarget(null);
    els.targetJp.textContent = 'やられた…';
    els.targetRomaji.textContent = '';

    var isNewBest = !practice && score > getBest();
    if (isNewBest) setBest(score);

    var gSpeed = gradeIndex(kps, [2, 3, 4, 5]);
    var gAcc = gradeIndex(acc, [80, 90, 95, 98]);
    var gWave = gradeIndex(wave, [3, 6, 9, 12]);
    var gCombo = gradeIndex(maxCombo, [5, 15, 30, 50]);
    var overall = Math.round((gSpeed + gAcc + gWave + gCombo) / 4);

    els.rTitle.textContent = titleFor(score);
    els.rScore.textContent = baseScore.toLocaleString();
    els.rKills.textContent = kills + '体';
    els.rWave.textContent = 'WAVE ' + wave;
    els.rTime.textContent = formatDuration(survived);
    els.rCombo.textContent = maxCombo;
    els.rAccuracy.textContent = acc + '%';
    els.rSpeed.textContent = kps.toFixed(1) + ' 打/秒';
    els.rPerfect.textContent = perfects + '回';
    els.rGrades.innerHTML =
      gradeCell('速さ', gSpeed) + gradeCell('正確さ', gAcc) + gradeCell('生存', gWave) + gradeCell('コンボ', gCombo);
    els.rOverall.textContent = GRADES[overall];
    els.rOverall.className = 'result-overall grade-' + GRADES[overall];
    els.rBonus.textContent = accBonus > 0 ? '正確さボーナス +' + accBonus.toLocaleString() : '正確さボーナスなし(80%以上で加算)';
    els.rBest.style.display = isNewBest && score > 0 ? 'block' : 'none';
    els.rPractice.style.display = practice ? 'block' : 'none';

    els.overlay.classList.add('active');
    updateHud();
    if (window.PlayTrack) PlayTrack.setExtra({ final_score: score, acc_bonus: accBonus, overall: GRADES[overall] });

    // スコアはカウントアップ演出のあとで確定表示 → ランキング登録へ
    animateScore(baseScore, score, 700, function () {
      if (practice) {
        if (window.PlayTrack) PlayTrack.complete({ result: 'finished', score: score, extra: { practice: 1 } });
      } else if (window.Leaderboard && score > 0) {
        Leaderboard.show('zombie-typing', score);
      }
    });
    phase = 'idle';
    syncIdleUi();
  }

  function animateScore(from, to, ms, done) {
    var t0 = performance.now();
    function step(now) {
      var k = Math.min(1, (now - t0) / ms);
      var v = Math.round(from + (to - from) * (1 - Math.pow(1 - k, 3)));
      els.rScore.textContent = v.toLocaleString();
      if (k < 1) requestAnimationFrame(step); else done();
    }
    requestAnimationFrame(step);
  }

  // ============================================
  // UIイベント
  // ============================================
  els.btnStart.addEventListener('click', function () {
    if (phase !== 'idle') return;
    // ユーザー操作の中で focus すると iOS でもソフトキーボードが出る
    if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) {
      els.mobileInput.focus({ preventScroll: true });
    }
    startGame();
  });

  els.btnRestart.addEventListener('click', function () {
    els.overlay.classList.remove('active');
    startGame();
  });

  els.overlay.addEventListener('click', function (e) {
    if (e.target === els.overlay) closeResult();
  });

  els.btnMute.addEventListener('click', function () {
    muted = GameAudio.toggleMute();
    updateMuteButton();
  });

  // 初期表示
  updateMuteButton();
  updateHud();
  syncIdleUi();
  applyScene(1);
  updateProps(0, 0);

  // 動作確認用フック(URL に #zt-debug を付けたときだけ)。ボスや投げ物を即出して演出を確かめる
  if (location.hash === '#zt-debug') {
    window.ZT_DEBUG = {
      spawnBoss: spawnBoss, spawnItem: spawnItem, spawnZombie: spawnZombie, spawnAmbush: spawnAmbush, spawnLurker: spawnLurker,
      dash: function () { dashUntil = performance.now() + 1600; },
      spawnThrow: function () { var t = entities.filter(function (e) { return e.alive && e.kind === 'zombie'; })[0]; if (t) spawnThrow(t); },
      setWave: function (w) { wave = w; startWave(performance.now()); },
      clearWave: function () { clearWave(performance.now()); },
      setHp: function (v) { hp = v; updateHud(); },
      quiz: function (n) { return buildQuizQuestions(n || 5); },
      nudge: function (kind, z) { entities.forEach(function (e) { if (e.alive && e.kind === kind) e.z = z; }); },
      freeze: function () { entities.forEach(function (e) { e.speed = 0; if (e.attackAt) e.attackAt += 60000; }); },
      state: function () { return { phase: phase, wave: wave, hp: hp, score: score, rank: rank, entities: entities.filter(function (e) { return e.alive; }).map(function (e) { return e.kind + ':' + e.type + ':' + (e.word ? e.word.d : '') + '@' + e.z.toFixed(2); }) }; }
    };
  }

})();

このコードで学べるポイント

自分のパソコンで動かすには

上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。

このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。

慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。

このコードは使っていいの?

このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。

ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。

もっと学びたい人へ

コードを読んで「自分でも作ってみたい!」と思ったら、ゾンビタイピングの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。2026年9月からはオンライン授業も始まり、全国どこからでも受講できます(土曜12:00と、月・水・金の20:00)。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、のぞいてみてください。