インベーダーゲームのソースコード全文 — JavaScript・HTML・CSS

▶ インベーダーゲームで遊ぶ 📖 作り方を1から学ぶ

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

このゲームは、隊列を組んで迫りくるエイリアンの大群を撃ち落とすクラシックなシューティングです。コードの見どころは、たくさんの敵を整列させていっせいに動かすしくみと、エイリアンを点ではなく小さな四角の集まりで描く「ピクセルアート」の表現。アーケードの名作がどう作られているかがよくわかります。

コードはコピーして自分のパソコンで自由に動かせます。敵の数や速さを変えれば、自分だけのむずかしさに調整できます。

インベーダーゲームは3つのファイルでできている

インベーダーゲームは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

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

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

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

index.htmlは、ブラウザが最初に読み込むファイルです。ここにはゲームの「部品」をHTMLで並べているだけで、動きそのものは書かれていません。

このゲームの主役は<canvas>というたった1つのタグです。自機・エイリアン・弾・バリア・UFO・星空まで、すべてこの1枚の板の上にJavaScriptが描いています。それ以外にあるのは、スコアとベストを出すscore-area、残り自機を表すlives-display、スタートとゲームオーバーのオーバーレイだけです。

盤面は<canvas>の中なので、HTMLを見てもエイリアンは1体も出てきません。

※ 下のコードでは、ゲームの動きと関係のない広告・アクセス解析用のタグ(ページ先頭の数行)を省いています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>インベーダーゲーム|無料ブラウザシューティング|ひなテックGames</title>
  <meta name="description" content="インベーダーゲームを無料で遊ぼう!迫りくるエイリアンの大群を撃ち落とせ。PC・スマホ対応の無料ブラウザシューティングゲーム。登録不要ですぐ遊べる!">
  <link rel="canonical" href="https://hinata-ya.tech/games/games/invader/">
  <!-- I18N:hreflang -->
  <link rel="alternate" hreflang="ja" href="https://hinata-ya.tech/games/games/invader/">
  <link rel="alternate" hreflang="en" href="https://hinata-ya.tech/games/en/games/invader/">
  <link rel="alternate" hreflang="x-default" href="https://hinata-ya.tech/games/games/invader/">
  <!-- /I18N:hreflang -->
  <meta property="og:title" content="インベーダーゲーム|無料ブラウザシューティング|ひなテックGames">
  <meta property="og:description" content="インベーダーゲームを無料で遊ぼう!迫りくるエイリアンの大群を撃ち落とせ。PC・スマホ対応の無料ブラウザシューティングゲーム。">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://hinata-ya.tech/games/games/invader/">
  <meta property="og:site_name" content="ひなテックGames">
  <meta property="og:locale" content="ja_JP">
  <!-- SEO:meta-extra -->
  <meta property="og:image" content="https://hinata-ya.tech/games/images/og/invader.png">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta property="og:image:alt" content="インベーダーゲーム">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="インベーダーゲーム|無料ブラウザシューティング|ひなテックGames">
  <meta name="twitter:description" content="インベーダーゲームを無料で遊ぼう!迫りくるエイリアンの大群を撃ち落とせ。PC・スマホ対応の無料ブラウザシューティングゲーム。登録不要ですぐ遊べる!">
  <meta name="twitter:image" content="https://hinata-ya.tech/games/images/og/invader.png">
  <!-- /SEO:meta-extra -->
  <link rel="stylesheet" href="../../css/style.css">
  <link rel="stylesheet" href="style.css">
  <link rel="stylesheet" href="../../css/leaderboard.css">
  <!-- SEO:ld+json -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "VideoGame",
    "name": "インベーダーゲーム",
    "description": "インベーダーゲームを無料で遊ぼう!迫りくるエイリアンの大群を撃ち落とせ。PC・スマホ対応の無料ブラウザシューティングゲーム。登録不要ですぐ遊べる!",
    "url": "https://hinata-ya.tech/games/games/invader/",
    "image": "https://hinata-ya.tech/games/images/og/invader.png",
    "inLanguage": "ja",
    "genre": [
      "シューティング",
      "クラシック"
    ],
    "gamePlatform": [
      "Web Browser"
    ],
    "applicationCategory": "GameApplication",
    "operatingSystem": [
      "Windows",
      "macOS",
      "iOS",
      "Android",
      "ChromeOS"
    ],
    "browserRequirements": "Requires JavaScript. Requires HTML5.",
    "isAccessibleForFree": true,
    "offers": {
      "@type": "Offer",
      "price": "0",
      "priceCurrency": "JPY",
      "availability": "https://schema.org/InStock"
    },
    "publisher": {
      "@type": "Organization",
      "name": "ひなテック",
      "url": "https://hinata-ya.tech"
    },
    "author": {
      "@type": "Organization",
      "name": "ひなテック",
      "url": "https://hinata-ya.tech"
    }
  }
  </script>
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "name": "ホーム",
        "item": "https://hinata-ya.tech/games/"
      },
      {
        "@type": "ListItem",
        "position": 2,
        "name": "インベーダーゲーム",
        "item": "https://hinata-ya.tech/games/games/invader/"
      }
    ]
  }
  </script>
  <!-- /SEO:ld+json -->
</head>
<body>
  <div id="header"></div>
  <div class="game-page">
    <h1 class="game-page-title">インベーダーゲーム</h1>
    <div class="game-page-tags">
      <span class="tag">シューティング</span>
      <span class="tag">クラシック</span>
    </div>
    <div class="game-container">
      <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>
        <button class="btn-new-game" id="btn-new-game">New Game</button>
      </div>
      <div class="board-wrapper">
        <canvas id="game-canvas"></canvas>
        <div class="lives-display" id="lives-display"></div>
        <div class="game-overlay" id="game-over-overlay">
          <div class="overlay-content">
            <h2>ゲームオーバー</h2>
            <p>スコア: <span id="final-score">0</span></p>
            <p class="best-result" id="best-result"></p>
            <button class="btn-primary" id="btn-retry">もう一度遊ぶ</button>
          </div>
        </div>
        <div class="game-overlay active" id="game-start-overlay">
          <div class="overlay-content">
            <h2>インベーダーゲーム</h2>
            <p>エイリアンの大群を<br>撃ち落とせ!</p>
            <button class="btn-primary" id="btn-start">ゲームスタート</button>
          </div>
        </div>
      </div>
    </div>
    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li><strong>PC</strong>: 左右の矢印キーで移動、スペースキーで弾を発射</li>
        <li><strong>スマホ</strong>: 画面の左半分タッチで左移動、右半分タッチで右移動(弾は自動発射)</li>
        <li>隊列を組んで迫りくるエイリアンを全滅させよう!</li>
        <li>エイリアンは端に到達すると一段降りてくる</li>
        <li>上段のエイリアンほど高得点(10〜30点)</li>
        <li>バリアで敵の弾を防げるが、ダメージを受けると壊れる</li>
        <li>全滅させるとウェーブクリア!次はさらに速く!</li>
      </ul>
    </div>
    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>インベーダーゲームはCanvas APIと<br>配列操作・当たり判定で作られています。<br><br>ひなテックでは、こうしたゲームの<br>作り方を楽しく学べます!</p>
      <div class="promo-links">
        <a href="https://hinata-ya.tech/contact/" class="btn-primary">体験レッスンに申し込む →</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
      </div>
    </div>
    <div class="other-games"><a href="../../index.html">&larr; 他のゲームも遊ぶ</a></div>
  </div>
  <div id="footer"></div>
  <script src="../../js/common.js?v=20260613c" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="game.js?v=20260610a"></script>
</body>
</html>

style.css — 見た目とレイアウト

style.cssは、ゲーム画面のまわりを整えているファイルです。自機や敵そのものの絵はCanvasにJavaScriptが描くので、ここで作っているのは主に「画面の枠」です。背景を#0a0a1aという暗い色にして、宇宙の雰囲気を出しています。

.board-wrapperaspect-ratio: 2 / 3を指定して、画面の大きさが変わってもゲーム画面の比が保たれるようにしています。touch-action: noneは、スマホで操作したときにページがスクロールしてしまわないための大事な指定です。

残り自機を表す.life-iconは緑の丸で、自機を失うと.lostクラスが付いてopacityでうすくなります。@keyframes overlay-appearはゲームオーバー画面がふわっと出る演出です。

style.css
/* インベーダーゲーム - 固有スタイル */
.score-area {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
}
.score-box {
  background: #BBADA0;
  border-radius: 8px;
  padding: 0.5rem 1rem;
  text-align: center;
  min-width: 70px;
}
.score-label {
  display: block;
  font-size: 0.7rem;
  color: #EEE4DA;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.score-value {
  display: block;
  font-size: 1.25rem;
  font-weight: 700;
  color: #fff;
}
.btn-new-game {
  margin-left: auto;
  background: #8F7A66;
  color: #fff;
  border: none;
  border-radius: 8px;
  padding: 0.5rem 1.25rem;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}
.btn-new-game:hover {
  background: #7A6658;
}
.board-wrapper {
  position: relative;
  width: 100%;
  max-width: 400px;
  margin: 0 auto;
  aspect-ratio: 2 / 3;
  touch-action: none;
}
#game-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  background: #0a0a1a;
  border-radius: 8px;
  display: block;
}
.lives-display {
  position: absolute;
  bottom: 8px;
  right: 12px;
  display: flex;
  gap: 4px;
  z-index: 5;
}
.life-icon {
  width: 12px;
  height: 12px;
  background: #33ff33;
  border-radius: 50%;
  opacity: 0.9;
}
.life-icon.lost {
  opacity: 0.25;
}
.game-overlay {
  position: absolute;
  inset: 0;
  background: rgba(238, 228, 218, 0.85);
  border-radius: 8px;
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 10;
  animation: overlay-appear 0.3s ease;
}
.game-overlay.active {
  display: flex;
}
@keyframes overlay-appear {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
.overlay-content {
  text-align: center;
  padding: 1.5rem;
}
.overlay-content h2 {
  font-size: 1.75rem;
  color: #776E65;
  margin-bottom: 0.5rem;
}
.overlay-content p {
  font-size: 1.1rem;
  color: #776E65;
  margin-bottom: 0.75rem;
}
.overlay-content .best-result {
  font-size: 0.95rem;
  color: #33ff33;
  font-weight: 600;
  margin-bottom: 1rem;
}
.overlay-content .btn-primary {
  margin-bottom: 0.5rem;
}
@media (max-width: 500px) {
  .board-wrapper { max-width: 100%; }
}
@media (max-width: 360px) {
  .score-area { gap: 0.5rem; }
  .score-box { padding: 0.4rem 0.75rem; min-width: 55px; }
  .score-value { font-size: 1rem; }
  .score-label { font-size: 0.6rem; }
}

game.js — ゲームのルールを動かす頭脳

game.jsがこのゲームの心臓部です。全体が(function () { ... })();という「即時実行関数」で囲まれ、ゲームの変数が他のコードとぶつからないようになっています。中心にあるのはgameLoop()というゲームループで、requestAnimationFrameでくり返され、毎回「更新→描画」を行います。

このゲームならではの面白さはエイリアンの隊列移動です。initInvaders()が5行8列の40体をinvadersという配列にきちんと並べます。動かすのはupdateInvaders()です。敵はいっせいに横へ進み、getGridBounds()で「隊列のいちばん端」が画面のはしに着いたかを調べます。はしに着くとneedDropが立って、全員が1段下がって進む向きを反対にします。あの「カクカク横に動いて、はしで降りる」動きはこうして作られています。

敵がだんだん速くなるのもこのゲームの大事な味です。updateInvaders()の中で、生き残っている敵が少ないほどspeedMult(速さの倍率)が大きくなる計算をしています。敵を倒すほど残りが加速し、最後の1体はものすごく速く動きます。これは本家インベーダーそっくりの仕掛けです。

エイリアンの絵はdrawSmallInvader()などの関数が描いています。注目したいのは、ctx.fillRectで小さな四角をいくつも並べて1体のエイリアンを形づくっているところ。これがピクセルアートです。さらにframeという値で足や腕の位置を変えた2パターンの絵を交互に出すことで、エイリアンがモゾモゾ動いて見えます。

当たり判定は四角どうしの重なり(rectsOverlap)で行います。自機の弾はエイリアン・UFO・バリアと、敵の弾は自機・バリアと判定されます。バリア(shields)は小さなブロックの集まりで、弾が当たったブロックだけalivefalseにして消すので、撃たれるほど少しずつ削れていきます。敵を全滅させるとcheckWaveClear()が反応し、次のウェーブはさらに速くなります。ベストスコアはlocalStorageに保存しています。

game.js
// ============================================
// インベーダーゲーム ゲームロジック
// ============================================

(function () {
  'use strict';

  // 言語別テキスト(英語版ページが window.GAME_TEXT を定義して上書きする。docs/i18n.md 参照)
  var TEXT = window.GAME_TEXT || {};

  // ============================================
  // 定数
  // ============================================
  var GRID_ROWS = 5;
  var GRID_COLS = 8;
  var MAX_LIVES = 3;
  var PLAYER_SPEED_RATIO = 0.012;
  var PLAYER_BULLET_SPEED_RATIO = 0.018;
  var ENEMY_BULLET_SPEED_RATIO = 0.004;
  var ENEMY_BASE_STEP_RATIO = 0.004;
  var ENEMY_DROP_RATIO = 0.025;
  var INVADER_WIDTH_RATIO = 0.055;
  var INVADER_HEIGHT_RATIO = 0.032;
  var INVADER_SPACING_X_RATIO = 0.075;
  var INVADER_SPACING_Y_RATIO = 0.05;
  var GRID_TOP_RATIO = 0.08;
  var GRID_MARGIN_RATIO = 0.04;
  var PLAYER_Y_RATIO = 0.90;
  var SHIELD_Y_RATIO = 0.78;
  var SHIELD_BLOCK_RATIO = 0.012;
  var UFO_SPEED_RATIO = 0.004;
  var UFO_WIDTH_RATIO = 0.08;
  var UFO_HEIGHT_RATIO = 0.03;
  var FIRE_COOLDOWN = 250;
  var ENEMY_FIRE_BASE_INTERVAL = 1800;
  var ENEMY_FIRE_INITIAL_DELAY = 2000;
  var UFO_MIN_INTERVAL = 15000;
  var UFO_MAX_INTERVAL = 25000;
  var WAVE_CLEAR_DURATION = 1500;
  var INVINCIBLE_DURATION = 1500;
  var MOVE_STEPS_PER_ANIM = 10;

  // ポイント値
  var ROW_POINTS = [30, 20, 20, 10, 10];
  var ROW_COLORS = ['#ff3333', '#33ccff', '#33ccff', '#33ff33', '#33ff33'];
  var UFO_POINTS = [100, 150, 200, 300];

  // ============================================
  // 状態変数
  // ============================================
  var canvas, ctx;
  var W, H;
  var dpr;

  // プレイヤー
  var playerX, playerW, playerH, playerSpeed;
  var playerBullet = null;
  var playerBulletSpeed;
  var lastFireTime = 0;
  var lives = MAX_LIVES;
  var invincibleUntil = 0;

  // 敵グリッド
  var invaders = [];
  var enemyDir = 1;
  var enemyStepX, enemyDropY;
  var enemyMoveTimer = 0;
  var enemyMoveInterval;
  var enemyAnimFrame = 0;
  var enemyAnimStepCount = 0;
  var totalEnemies = GRID_ROWS * GRID_COLS;

  // 敵弾
  var enemyBullets = [];
  var enemyBulletSpeed;
  var lastEnemyFireTime = 0;
  var enemyFireInterval;
  var gameStartTime = 0;

  // シールド
  var shields = [];

  // UFO
  var ufo = null;
  var nextUfoTime = 0;

  // パーティクル・エフェクト
  var particles = [];
  var floatingTexts = [];

  // 背景の星
  var stars = [];

  // ゲーム状態
  var score = 0;
  var bestScore = parseInt(localStorage.getItem('bestInvader') || '0', 10);
  var wave = 1;
  var gameRunning = false;
  var gameOverFlag = false;
  var waveClearFlag = false;
  var waveClearTime = 0;
  var animFrameId = null;
  var lastTime = 0;

  // 入力状態
  var leftPressed = false;
  var rightPressed = false;
  var spacePressed = false;
  var touchSide = null;
  var touchAutoFireTimer = 0;

  // ============================================
  // DOM要素
  // ============================================
  var scoreEl = document.getElementById('score');
  var bestScoreEl = document.getElementById('best-score');
  var livesDisplay = document.getElementById('lives-display');
  var gameOverOverlay = document.getElementById('game-over-overlay');
  var gameStartOverlay = document.getElementById('game-start-overlay');
  var finalScoreEl = document.getElementById('final-score');
  var bestResultEl = document.getElementById('best-result');

  // ============================================
  // キャンバスの初期化
  // ============================================
  canvas = document.getElementById('game-canvas');
  ctx = canvas.getContext('2d');

  function resizeCanvas() {
    var wrapper = canvas.parentElement;
    var rect = wrapper.getBoundingClientRect();
    dpr = window.devicePixelRatio || 1;

    W = rect.width;
    H = rect.height;

    canvas.width = W * dpr;
    canvas.height = H * dpr;
    canvas.style.width = W + 'px';
    canvas.style.height = H + 'px';

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    // サイズ依存値の再計算
    playerW = W * 0.07;
    playerH = H * 0.035;
    playerSpeed = W * PLAYER_SPEED_RATIO;
    playerBulletSpeed = H * PLAYER_BULLET_SPEED_RATIO;
    enemyBulletSpeed = H * ENEMY_BULLET_SPEED_RATIO;
    enemyStepX = W * ENEMY_BASE_STEP_RATIO;
    enemyDropY = H * ENEMY_DROP_RATIO;

    // プレイヤー位置を制限
    if (playerX !== undefined) {
      playerX = clamp(playerX, playerW / 2, W - playerW / 2);
    }

    initStars();
  }

  // ============================================
  // ユーティリティ
  // ============================================
  function clamp(val, min, max) {
    return val < min ? min : (val > max ? max : val);
  }

  function randInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  function randFloat(min, max) {
    return Math.random() * (max - min) + min;
  }

  function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
    return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
  }

  // ============================================
  // 背景の星
  // ============================================
  function initStars() {
    stars = [];
    var count = Math.floor(W * H / 700);
    for (var i = 0; i < count; i++) {
      stars.push({
        x: Math.random() * W,
        y: Math.random() * H,
        size: Math.random() * 1.5 + 0.5,
        speed: Math.random() * 0.3 + 0.1,
        alpha: Math.random() * 0.5 + 0.2
      });
    }
  }

  function updateStars(dt) {
    for (var i = 0; i < stars.length; i++) {
      var s = stars[i];
      s.y += s.speed * dt * 0.06;
      if (s.y > H) {
        s.y = 0;
        s.x = Math.random() * W;
      }
    }
  }

  function drawStars() {
    for (var i = 0; i < stars.length; i++) {
      var s = stars[i];
      ctx.fillStyle = 'rgba(255,255,255,' + s.alpha + ')';
      ctx.fillRect(s.x, s.y, s.size, s.size);
    }
  }

  // ============================================
  // ライフ表示
  // ============================================
  function updateLivesDisplay() {
    livesDisplay.innerHTML = '';
    for (var i = 0; i < MAX_LIVES; i++) {
      var dot = document.createElement('div');
      dot.className = 'life-icon' + (i >= lives ? ' lost' : '');
      livesDisplay.appendChild(dot);
    }
  }

  // ============================================
  // スコア更新
  // ============================================
  function updateScore(pts) {
    score += pts;
    scoreEl.textContent = score;
    if (score > bestScore) {
      bestScore = score;
      bestScoreEl.textContent = bestScore;
      localStorage.setItem('bestInvader', bestScore);
    }
  }

  // ============================================
  // インベーダー描画 (fillRect ピクセルアート)
  // ============================================

  // 小型インベーダー (row 0) - 触角つき小さいボディ
  function drawSmallInvader(cx, cy, w, h, frame, color) {
    var u = w / 8;
    var v = h / 6;
    ctx.fillStyle = color;

    if (frame === 0) {
      // 触角
      ctx.fillRect(cx - 3 * u, cy - 3 * v, u, 2 * v);
      ctx.fillRect(cx + 2 * u, cy - 3 * v, u, 2 * v);
      // 頭
      ctx.fillRect(cx - 2 * u, cy - 1 * v, 4 * u, 2 * v);
      // 目
      ctx.fillStyle = '#0a0a1a';
      ctx.fillRect(cx - 1.5 * u, cy - 0.5 * v, u, v);
      ctx.fillRect(cx + 0.5 * u, cy - 0.5 * v, u, v);
      ctx.fillStyle = color;
      // ボディ
      ctx.fillRect(cx - 3 * u, cy + 1 * v, 6 * u, 2 * v);
      // 足
      ctx.fillRect(cx - 2 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx + 1 * u, cy + 3 * v, u, v);
    } else {
      // 触角(開き)
      ctx.fillRect(cx - 4 * u, cy - 3 * v, u, v);
      ctx.fillRect(cx - 3 * u, cy - 2 * v, u, v);
      ctx.fillRect(cx + 2 * u, cy - 2 * v, u, v);
      ctx.fillRect(cx + 3 * u, cy - 3 * v, u, v);
      // 頭
      ctx.fillRect(cx - 2 * u, cy - 1 * v, 4 * u, 2 * v);
      // 目
      ctx.fillStyle = '#0a0a1a';
      ctx.fillRect(cx - 1.5 * u, cy - 0.5 * v, u, v);
      ctx.fillRect(cx + 0.5 * u, cy - 0.5 * v, u, v);
      ctx.fillStyle = color;
      // ボディ
      ctx.fillRect(cx - 3 * u, cy + 1 * v, 6 * u, 2 * v);
      // 足(広がり)
      ctx.fillRect(cx - 3 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx + 2 * u, cy + 3 * v, u, v);
    }
  }

  // 中型インベーダー (rows 1-2) - 腕つき
  function drawMediumInvader(cx, cy, w, h, frame, color) {
    var u = w / 10;
    var v = h / 6;
    ctx.fillStyle = color;

    // 頭部
    ctx.fillRect(cx - 2 * u, cy - 3 * v, 4 * u, 2 * v);
    // 目
    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(cx - 1.5 * u, cy - 2.5 * v, u, v);
    ctx.fillRect(cx + 0.5 * u, cy - 2.5 * v, u, v);
    ctx.fillStyle = color;
    // ボディ
    ctx.fillRect(cx - 4 * u, cy - 1 * v, 8 * u, 3 * v);

    if (frame === 0) {
      // 腕下がり
      ctx.fillRect(cx - 5 * u, cy + 0 * v, u, 2 * v);
      ctx.fillRect(cx + 4 * u, cy + 0 * v, u, 2 * v);
      // 足
      ctx.fillRect(cx - 3 * u, cy + 2 * v, 2 * u, v);
      ctx.fillRect(cx + 1 * u, cy + 2 * v, 2 * u, v);
    } else {
      // 腕上がり
      ctx.fillRect(cx - 5 * u, cy - 2 * v, u, 2 * v);
      ctx.fillRect(cx + 4 * u, cy - 2 * v, u, 2 * v);
      // 足(広がり)
      ctx.fillRect(cx - 4 * u, cy + 2 * v, 2 * u, v);
      ctx.fillRect(cx + 2 * u, cy + 2 * v, 2 * u, v);
    }
  }

  // 大型インベーダー (rows 3-4) - 脚つき
  function drawLargeInvader(cx, cy, w, h, frame, color) {
    var u = w / 12;
    var v = h / 7;
    ctx.fillStyle = color;

    // 頭
    ctx.fillRect(cx - 3 * u, cy - 3 * v, 6 * u, 2 * v);
    // 目
    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(cx - 2 * u, cy - 2.5 * v, 1.5 * u, v);
    ctx.fillRect(cx + 0.5 * u, cy - 2.5 * v, 1.5 * u, v);
    ctx.fillStyle = color;
    // ボディ
    ctx.fillRect(cx - 5 * u, cy - 1 * v, 10 * u, 3 * v);
    // 下ボディ
    ctx.fillRect(cx - 4 * u, cy + 2 * v, 8 * u, v);

    if (frame === 0) {
      // 足(内側)
      ctx.fillRect(cx - 4 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx - 1 * u, cy + 3 * v, 2 * u, v);
      ctx.fillRect(cx + 3 * u, cy + 3 * v, u, v);
    } else {
      // 足(外側)
      ctx.fillRect(cx - 5 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx - 2 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx + 1 * u, cy + 3 * v, u, v);
      ctx.fillRect(cx + 4 * u, cy + 3 * v, u, v);
    }
  }

  function drawInvader(inv) {
    var drawFn;
    if (inv.row === 0) {
      drawFn = drawSmallInvader;
    } else if (inv.row <= 2) {
      drawFn = drawMediumInvader;
    } else {
      drawFn = drawLargeInvader;
    }
    drawFn(inv.x, inv.y, inv.w, inv.h, enemyAnimFrame, inv.color);
  }

  // ============================================
  // インベーダーグリッドの初期化
  // ============================================
  function initInvaders() {
    invaders = [];
    var invW = W * INVADER_WIDTH_RATIO;
    var invH = H * INVADER_HEIGHT_RATIO;
    var spacingX = W * INVADER_SPACING_X_RATIO;
    var spacingY = H * INVADER_SPACING_Y_RATIO;
    var gridW = GRID_COLS * spacingX;
    var startX = (W - gridW) / 2 + spacingX / 2;
    // ウェーブが進むと少しだけ低くスタート(最大で少し下)
    var extraDrop = Math.min((wave - 1) * invH * 0.4, H * 0.1);
    var startY = H * GRID_TOP_RATIO + extraDrop;

    for (var r = 0; r < GRID_ROWS; r++) {
      for (var c = 0; c < GRID_COLS; c++) {
        invaders.push({
          row: r,
          col: c,
          x: startX + c * spacingX,
          y: startY + r * spacingY,
          w: invW,
          h: invH,
          alive: true,
          color: ROW_COLORS[r],
          points: ROW_POINTS[r]
        });
      }
    }

    totalEnemies = GRID_ROWS * GRID_COLS;
    enemyDir = 1;
    enemyAnimFrame = 0;
    enemyAnimStepCount = 0;
    enemyMoveTimer = 0;
    enemyMoveInterval = 45; // 正規化dt単位の移動間隔
  }

  function getAliveCount() {
    var count = 0;
    for (var i = 0; i < invaders.length; i++) {
      if (invaders[i].alive) count++;
    }
    return count;
  }

  function getGridBounds() {
    var minX = Infinity, maxX = -Infinity;
    var maxY = -Infinity;
    for (var i = 0; i < invaders.length; i++) {
      var inv = invaders[i];
      if (!inv.alive) continue;
      var left = inv.x - inv.w / 2;
      var right = inv.x + inv.w / 2;
      var bottom = inv.y + inv.h / 2;
      if (left < minX) minX = left;
      if (right > maxX) maxX = right;
      if (bottom > maxY) maxY = bottom;
    }
    return { minX: minX, maxX: maxX, maxY: maxY };
  }

  // ============================================
  // 敵移動
  // ============================================
  function updateInvaders(dt) {
    var alive = getAliveCount();
    if (alive === 0) return;

    // スピードアップ: 残り少ないほど速い
    var speedMult = 1 + 0.5 * (totalEnemies - alive) / totalEnemies;
    var waveSpeedMult = 1 + (wave - 1) * 0.12;
    var interval = enemyMoveInterval / (speedMult * waveSpeedMult);

    enemyMoveTimer += dt;

    if (enemyMoveTimer < interval) return;
    enemyMoveTimer = 0;

    // アニメフレーム切り替え
    enemyAnimStepCount++;
    if (enemyAnimStepCount >= MOVE_STEPS_PER_ANIM) {
      enemyAnimStepCount = 0;
      enemyAnimFrame = 1 - enemyAnimFrame;
    }

    var bounds = getGridBounds();
    var stepX = enemyStepX;
    var margin = W * GRID_MARGIN_RATIO;
    var needDrop = false;

    if (enemyDir === 1 && bounds.maxX + stepX > W - margin) {
      needDrop = true;
    } else if (enemyDir === -1 && bounds.minX - stepX < margin) {
      needDrop = true;
    }

    for (var i = 0; i < invaders.length; i++) {
      if (!invaders[i].alive) continue;
      if (needDrop) {
        invaders[i].y += enemyDropY;
      } else {
        invaders[i].x += stepX * enemyDir;
      }
    }

    if (needDrop) {
      enemyDir = -enemyDir;
    }

    // ゲームオーバー判定: 敵がプレイヤーラインに到達
    var playerLineY = H * PLAYER_Y_RATIO - playerH;
    bounds = getGridBounds();
    if (bounds.maxY >= playerLineY) {
      triggerGameOver();
    }
  }

  // ============================================
  // プレイヤー
  // ============================================
  function initPlayer() {
    playerX = W / 2;
    lives = MAX_LIVES;
    invincibleUntil = 0;
    playerBullet = null;
    updateLivesDisplay();
  }

  function drawPlayer() {
    var now = Date.now();
    // 無敵中は点滅
    if (now < invincibleUntil) {
      if (Math.floor(now / 100) % 2 === 0) return;
    }

    var px = playerX;
    var py = H * PLAYER_Y_RATIO;
    var hw = playerW / 2;
    var hh = playerH / 2;

    ctx.fillStyle = '#33ff33';
    // 本体(長方形)
    ctx.fillRect(px - hw, py - hh + hh * 0.4, playerW, playerH * 0.6);
    // 砲台(上部)
    var barrelW = playerW * 0.2;
    var barrelH = playerH * 0.5;
    ctx.fillRect(px - barrelW / 2, py - hh - barrelH * 0.3, barrelW, barrelH);
    // 中央コア
    ctx.fillStyle = '#22cc22';
    ctx.fillRect(px - hw * 0.4, py - hh * 0.2, playerW * 0.4, playerH * 0.3);
  }

  function updatePlayer(dt) {
    if (leftPressed || touchSide === 'left') {
      playerX -= playerSpeed * dt;
    }
    if (rightPressed || touchSide === 'right') {
      playerX += playerSpeed * dt;
    }
    playerX = clamp(playerX, playerW / 2, W - playerW / 2);

    // モバイル自動発射
    if (touchSide !== null) {
      touchAutoFireTimer += dt;
      if (touchAutoFireTimer > 5) {
        firePlayerBullet();
      }
    }

    // PC: スペースキー長押し
    if (spacePressed) {
      firePlayerBullet();
    }
  }

  // ============================================
  // プレイヤー弾
  // ============================================
  function firePlayerBullet() {
    if (playerBullet !== null) return;
    var now = Date.now();
    if (now - lastFireTime < FIRE_COOLDOWN) return;
    lastFireTime = now;

    playerBullet = {
      x: playerX,
      y: H * PLAYER_Y_RATIO - playerH,
      w: 3,
      h: 10
    };
  }

  function updatePlayerBullet(dt) {
    if (playerBullet === null) return;
    playerBullet.y -= playerBulletSpeed * dt;
    if (playerBullet.y + playerBullet.h < 0) {
      playerBullet = null;
      return;
    }

    // 敵との衝突判定
    var b = playerBullet;
    for (var i = 0; i < invaders.length; i++) {
      var inv = invaders[i];
      if (!inv.alive) continue;
      if (rectsOverlap(
        b.x - b.w / 2, b.y - b.h / 2, b.w, b.h,
        inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h
      )) {
        inv.alive = false;
        playerBullet = null;
        updateScore(inv.points);
        spawnExplosion(inv.x, inv.y, inv.color);
        addFloatingText('+' + inv.points, inv.x, inv.y, inv.color);
        return;
      }
    }

    // UFOとの衝突判定
    if (ufo !== null) {
      if (rectsOverlap(
        b.x - b.w / 2, b.y - b.h / 2, b.w, b.h,
        ufo.x - ufo.w / 2, ufo.y - ufo.h / 2, ufo.w, ufo.h
      )) {
        playerBullet = null;
        var pts = ufo.points;
        updateScore(pts);
        spawnExplosion(ufo.x, ufo.y, '#ff3333');
        addFloatingText('+' + pts, ufo.x, ufo.y, '#ff3333');
        ufo = null;
        return;
      }
    }

    // シールドとの衝突判定
    if (checkBulletShieldCollision(b)) {
      playerBullet = null;
    }
  }

  function drawPlayerBullet() {
    if (playerBullet === null) return;
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(
      playerBullet.x - playerBullet.w / 2,
      playerBullet.y - playerBullet.h / 2,
      playerBullet.w,
      playerBullet.h
    );
  }

  // ============================================
  // 敵弾
  // ============================================
  function updateEnemyFiring(dt) {
    var now = Date.now();

    // ゲーム開始直後は発射しない
    if (now - gameStartTime < ENEMY_FIRE_INITIAL_DELAY) return;

    var interval = ENEMY_FIRE_BASE_INTERVAL - (wave - 1) * 120;
    if (interval < 500) interval = 500;
    enemyFireInterval = interval;

    if (now - lastEnemyFireTime < enemyFireInterval) return;

    // 最下段の敵から発射
    var bottomInvaders = getBottomInvaders();
    if (bottomInvaders.length === 0) return;

    var shooter = bottomInvaders[randInt(0, bottomInvaders.length - 1)];
    enemyBullets.push({
      x: shooter.x,
      y: shooter.y + shooter.h / 2,
      w: 3,
      h: 8
    });
    lastEnemyFireTime = now;
  }

  function getBottomInvaders() {
    var colBottom = {};
    for (var i = 0; i < invaders.length; i++) {
      var inv = invaders[i];
      if (!inv.alive) continue;
      var key = inv.col;
      if (!colBottom[key] || inv.row > colBottom[key].row) {
        colBottom[key] = inv;
      }
    }
    var result = [];
    for (var k in colBottom) {
      if (colBottom.hasOwnProperty(k)) {
        result.push(colBottom[k]);
      }
    }
    return result;
  }

  function updateEnemyBullets(dt) {
    for (var i = enemyBullets.length - 1; i >= 0; i--) {
      var b = enemyBullets[i];
      b.y += enemyBulletSpeed * dt;

      // 画面外
      if (b.y > H) {
        enemyBullets.splice(i, 1);
        continue;
      }

      // プレイヤーとの衝突
      var now = Date.now();
      if (now >= invincibleUntil) {
        var px = playerX - playerW / 2;
        var py = H * PLAYER_Y_RATIO - playerH / 2;
        if (rectsOverlap(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, px, py, playerW, playerH)) {
          enemyBullets.splice(i, 1);
          hitPlayer();
          continue;
        }
      }

      // シールドとの衝突
      if (checkBulletShieldCollision(b)) {
        enemyBullets.splice(i, 1);
      }
    }
  }

  function drawEnemyBullets() {
    ctx.fillStyle = '#ffff33';
    for (var i = 0; i < enemyBullets.length; i++) {
      var b = enemyBullets[i];
      ctx.fillRect(b.x - b.w / 2, b.y - b.h / 2, b.w, b.h);
    }
  }

  // ============================================
  // シールド
  // ============================================
  function initShields() {
    shields = [];
    var shieldCount = 4;
    var blockSize = W * SHIELD_BLOCK_RATIO;
    if (blockSize < 3) blockSize = 3;
    var bw = 5; // ブロック数(横)
    var bh = 4; // ブロック数(縦)
    var shieldW = bw * blockSize;
    var totalShieldsW = shieldCount * shieldW;
    var gap = (W - totalShieldsW) / (shieldCount + 1);
    var shieldY = H * SHIELD_Y_RATIO;

    for (var s = 0; s < shieldCount; s++) {
      var sx = gap + s * (shieldW + gap);
      var shieldBlocks = [];
      for (var r = 0; r < bh; r++) {
        for (var c = 0; c < bw; c++) {
          // 下段中央をくりぬいてアーチ型にする
          if (r >= bh - 2 && c >= 1 && c <= 3) continue;
          shieldBlocks.push({
            x: sx + c * blockSize,
            y: shieldY + r * blockSize,
            w: blockSize,
            h: blockSize,
            alive: true
          });
        }
      }
      shields.push(shieldBlocks);
    }
  }

  function drawShields() {
    ctx.fillStyle = '#33ff33';
    for (var s = 0; s < shields.length; s++) {
      var blocks = shields[s];
      for (var i = 0; i < blocks.length; i++) {
        if (!blocks[i].alive) continue;
        ctx.fillRect(blocks[i].x, blocks[i].y, blocks[i].w, blocks[i].h);
      }
    }
  }

  function checkBulletShieldCollision(bullet) {
    for (var s = 0; s < shields.length; s++) {
      var blocks = shields[s];
      for (var i = 0; i < blocks.length; i++) {
        var block = blocks[i];
        if (!block.alive) continue;
        if (rectsOverlap(
          bullet.x - bullet.w / 2, bullet.y - bullet.h / 2, bullet.w, bullet.h,
          block.x, block.y, block.w, block.h
        )) {
          block.alive = false;
          spawnSmallExplosion(block.x + block.w / 2, block.y + block.h / 2, '#33ff33');
          return true;
        }
      }
    }
    return false;
  }

  // インベーダーがシールドに当たったらシールド破壊
  function checkInvaderShieldCollision() {
    for (var i = 0; i < invaders.length; i++) {
      var inv = invaders[i];
      if (!inv.alive) continue;
      for (var s = 0; s < shields.length; s++) {
        var blocks = shields[s];
        for (var j = 0; j < blocks.length; j++) {
          var block = blocks[j];
          if (!block.alive) continue;
          if (rectsOverlap(
            inv.x - inv.w / 2, inv.y - inv.h / 2, inv.w, inv.h,
            block.x, block.y, block.w, block.h
          )) {
            block.alive = false;
          }
        }
      }
    }
  }

  // ============================================
  // UFO
  // ============================================
  function scheduleUfo() {
    nextUfoTime = Date.now() + randInt(UFO_MIN_INTERVAL, UFO_MAX_INTERVAL);
  }

  function updateUfo(dt) {
    var now = Date.now();
    if (ufo === null) {
      if (now >= nextUfoTime) {
        ufo = {
          x: -W * UFO_WIDTH_RATIO,
          y: H * 0.04,
          w: W * UFO_WIDTH_RATIO,
          h: H * UFO_HEIGHT_RATIO,
          speed: W * UFO_SPEED_RATIO,
          points: UFO_POINTS[randInt(0, UFO_POINTS.length - 1)],
          animTimer: 0
        };
      }
      return;
    }

    ufo.x += ufo.speed * dt;
    ufo.animTimer += dt;
    if (ufo.x - ufo.w / 2 > W) {
      ufo = null;
      scheduleUfo();
    }
  }

  function drawUfo() {
    if (ufo === null) return;
    var cx = ufo.x;
    var cy = ufo.y;
    var u = ufo.w / 12;
    var v = ufo.h / 4;

    ctx.fillStyle = '#ff3333';
    // ドーム
    ctx.fillRect(cx - 2 * u, cy - 2 * v, 4 * u, v);
    // 本体
    ctx.fillRect(cx - 5 * u, cy - v, 10 * u, 2 * v);
    // 下部
    ctx.fillRect(cx - 3 * u, cy + v, 6 * u, v);

    // ライト点滅
    var blink = Math.floor(ufo.animTimer / 8) % 3;
    var lightPositions = [-3 * u, 0, 3 * u];
    for (var i = 0; i < lightPositions.length; i++) {
      ctx.fillStyle = (i === blink) ? '#ffff33' : '#ff6666';
      ctx.fillRect(cx + lightPositions[i] - u * 0.4, cy - 0.5 * v, u * 0.8, v * 0.5);
    }
  }

  // ============================================
  // プレイヤー被弾
  // ============================================
  function hitPlayer() {
    lives--;
    updateLivesDisplay();
    spawnExplosion(playerX, H * PLAYER_Y_RATIO, '#33ff33');

    if (lives <= 0) {
      triggerGameOver();
    } else {
      invincibleUntil = Date.now() + INVINCIBLE_DURATION;
    }
  }

  // ============================================
  // パーティクルエフェクト
  // ============================================
  function spawnExplosion(x, y, color) {
    var count = 12;
    for (var i = 0; i < count; i++) {
      var angle = (Math.PI * 2 / count) * i + Math.random() * 0.3;
      var speed = randFloat(1, 3);
      particles.push({
        x: x,
        y: y,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        life: 1.0,
        decay: randFloat(0.015, 0.03),
        size: randFloat(2, 5),
        color: color
      });
    }
  }

  function spawnSmallExplosion(x, y, color) {
    var count = 4;
    for (var i = 0; i < count; i++) {
      var angle = Math.random() * Math.PI * 2;
      var speed = randFloat(0.5, 1.5);
      particles.push({
        x: x,
        y: y,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        life: 1.0,
        decay: randFloat(0.03, 0.06),
        size: randFloat(1, 3),
        color: color
      });
    }
  }

  function updateParticles(dt) {
    for (var i = particles.length - 1; i >= 0; i--) {
      var p = particles[i];
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.life -= p.decay * dt;
      if (p.life <= 0) {
        particles.splice(i, 1);
      }
    }
  }

  function drawParticles() {
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      ctx.globalAlpha = p.life;
      ctx.fillStyle = p.color;
      ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
    }
    ctx.globalAlpha = 1;
  }

  // ============================================
  // フローティングテキスト
  // ============================================
  function addFloatingText(text, x, y, color) {
    floatingTexts.push({
      text: text,
      x: x,
      y: y,
      life: 1.0,
      decay: 0.02,
      color: color
    });
  }

  function updateFloatingTexts(dt) {
    for (var i = floatingTexts.length - 1; i >= 0; i--) {
      var t = floatingTexts[i];
      t.y -= 0.5 * dt;
      t.life -= t.decay * dt;
      if (t.life <= 0) {
        floatingTexts.splice(i, 1);
      }
    }
  }

  function drawFloatingTexts() {
    for (var i = 0; i < floatingTexts.length; i++) {
      var t = floatingTexts[i];
      ctx.globalAlpha = t.life;
      ctx.fillStyle = t.color;
      ctx.font = 'bold ' + Math.floor(W * 0.035) + 'px monospace';
      ctx.textAlign = 'center';
      ctx.fillText(t.text, t.x, t.y);
    }
    ctx.globalAlpha = 1;
  }

  // ============================================
  // ウェーブクリア
  // ============================================
  function checkWaveClear() {
    if (waveClearFlag) return;
    var alive = getAliveCount();
    if (alive === 0) {
      waveClearFlag = true;
      waveClearTime = Date.now();
      enemyBullets = [];
    }
  }

  function updateWaveClear() {
    if (!waveClearFlag) return;
    var now = Date.now();
    if (now - waveClearTime >= WAVE_CLEAR_DURATION) {
      wave++;
      waveClearFlag = false;
      initInvaders();
      initShields();
      scheduleUfo();
      ufo = null;
      playerBullet = null;
      enemyBullets = [];
      lastEnemyFireTime = now;
      gameStartTime = now; // ウェーブ開始時も発射ディレイを入れる
    }
  }

  function drawWaveClearText() {
    if (!waveClearFlag) return;
    var elapsed = Date.now() - waveClearTime;
    var alpha = 1;
    if (elapsed > WAVE_CLEAR_DURATION * 0.7) {
      alpha = 1 - (elapsed - WAVE_CLEAR_DURATION * 0.7) / (WAVE_CLEAR_DURATION * 0.3);
    }
    ctx.globalAlpha = clamp(alpha, 0, 1);
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold ' + Math.floor(W * 0.08) + 'px monospace';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('WAVE CLEAR!', W / 2, H * 0.4);
    ctx.font = Math.floor(W * 0.05) + 'px monospace';
    ctx.fillText('WAVE ' + (wave + 1), W / 2, H * 0.48);
    ctx.globalAlpha = 1;
    ctx.textBaseline = 'alphabetic';
  }

  // ============================================
  // ウェーブ表示
  // ============================================
  function drawWaveNumber() {
    ctx.fillStyle = 'rgba(255,255,255,0.4)';
    ctx.font = Math.floor(W * 0.03) + 'px monospace';
    ctx.textAlign = 'left';
    ctx.fillText('WAVE ' + wave, 8, H * 0.03);
  }

  // ============================================
  // ゲームオーバー
  // ============================================
  function triggerGameOver() {
    gameRunning = false;
    gameOverFlag = true;

    finalScoreEl.textContent = score;
    if (score > 0 && score >= bestScore) {
      bestResultEl.textContent = 'NEW BEST!';
      bestResultEl.style.color = '#33ff33';
    } else {
      bestResultEl.textContent = (TEXT.best || 'ベスト: ') + bestScore;
      bestResultEl.style.color = '#776E65';
    }

    gameOverOverlay.classList.add('active');
      if (window.Leaderboard) Leaderboard.show('invader', score);
  }

  // ============================================
  // ゲーム初期化・開始
  // ============================================
  function startGame() {
    score = 0;
    wave = 1;
    gameRunning = true;
    gameOverFlag = false;
    waveClearFlag = false;
    particles = [];
    floatingTexts = [];
    enemyBullets = [];
    playerBullet = null;
    ufo = null;
    lastFireTime = 0;
    gameStartTime = Date.now();
    lastEnemyFireTime = Date.now();
    touchAutoFireTimer = 0;

    scoreEl.textContent = '0';
    bestScoreEl.textContent = bestScore;

    gameOverOverlay.classList.remove('active');
    gameStartOverlay.classList.remove('active');

    resizeCanvas();
    initPlayer();
    initInvaders();
    initShields();
    scheduleUfo();
    lastTime = 0;

    if (animFrameId) cancelAnimationFrame(animFrameId);
    animFrameId = requestAnimationFrame(gameLoop);
  }

  // ============================================
  // ゲームループ
  // ============================================
  function gameLoop(timestamp) {
    if (!lastTime) lastTime = timestamp;
    var rawDt = timestamp - lastTime;
    lastTime = timestamp;

    // dtを制限 (タブ切り替え時の巨大dt防止)
    var dt = Math.min(rawDt, 50) / (1000 / 60);

    // 更新
    updateStars(dt);

    if (gameRunning && !waveClearFlag) {
      updatePlayer(dt);
      updateInvaders(dt);
      updatePlayerBullet(dt);
      updateEnemyFiring(dt);
      updateEnemyBullets(dt);
      updateUfo(dt);
      checkInvaderShieldCollision();
      checkWaveClear();
    }

    updateWaveClear();
    updateParticles(dt);
    updateFloatingTexts(dt);

    // 描画
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#0a0a1a';
    ctx.fillRect(0, 0, W, H);

    drawStars();
    drawWaveNumber();
    drawShields();

    // 生きているインベーダーを描画
    for (var i = 0; i < invaders.length; i++) {
      if (invaders[i].alive) {
        drawInvader(invaders[i]);
      }
    }

    drawUfo();

    if (gameRunning || waveClearFlag) {
      drawPlayer();
    }

    drawPlayerBullet();
    drawEnemyBullets();
    drawParticles();
    drawFloatingTexts();
    drawWaveClearText();

    animFrameId = requestAnimationFrame(gameLoop);
  }

  // ============================================
  // 入力処理
  // ============================================
  function onKeyDown(e) {
    if (!gameRunning) return;
    switch (e.key) {
      case 'ArrowLeft':
      case 'a':
      case 'A':
        leftPressed = true;
        e.preventDefault();
        break;
      case 'ArrowRight':
      case 'd':
      case 'D':
        rightPressed = true;
        e.preventDefault();
        break;
      case ' ':
        spacePressed = true;
        e.preventDefault();
        break;
    }
  }

  function onKeyUp(e) {
    switch (e.key) {
      case 'ArrowLeft':
      case 'a':
      case 'A':
        leftPressed = false;
        break;
      case 'ArrowRight':
      case 'd':
      case 'D':
        rightPressed = false;
        break;
      case ' ':
        spacePressed = false;
        break;
    }
  }

  function getTouchSide(touch) {
    var rect = canvas.getBoundingClientRect();
    var x = touch.clientX - rect.left;
    var mid = rect.width / 2;
    return x < mid ? 'left' : 'right';
  }

  function onTouchStart(e) {
    if (!gameRunning) return;
    e.preventDefault();
    if (e.touches.length > 0) {
      touchSide = getTouchSide(e.touches[0]);
      touchAutoFireTimer = 0;
      firePlayerBullet();
    }
  }

  function onTouchMove(e) {
    if (!gameRunning) return;
    e.preventDefault();
    if (e.touches.length > 0) {
      touchSide = getTouchSide(e.touches[0]);
    }
  }

  function onTouchEnd(e) {
    e.preventDefault();
    if (e.touches.length === 0) {
      touchSide = null;
      touchAutoFireTimer = 0;
    } else {
      touchSide = getTouchSide(e.touches[0]);
    }
  }

  // ============================================
  // Visibility change (タブ非表示時にポーズ)
  // ============================================
  function onVisibilityChange() {
    if (document.hidden) {
      leftPressed = false;
      rightPressed = false;
      spacePressed = false;
      touchSide = null;
    }
  }

  // ============================================
  // イベントリスナー登録
  // ============================================
  window.addEventListener('resize', resizeCanvas);
  document.addEventListener('keydown', onKeyDown);
  document.addEventListener('keyup', onKeyUp);
  canvas.addEventListener('touchstart', onTouchStart, { passive: false });
  canvas.addEventListener('touchmove', onTouchMove, { passive: false });
  canvas.addEventListener('touchend', onTouchEnd, { passive: false });
  document.addEventListener('visibilitychange', onVisibilityChange);

  document.getElementById('btn-start').addEventListener('click', function () {
    startGame();
  });
  document.getElementById('btn-retry').addEventListener('click', function () {
    startGame();
  });
  document.getElementById('btn-new-game').addEventListener('click', function () {
    startGame();
  });

  // ============================================
  // 初期表示
  // ============================================
  bestScoreEl.textContent = bestScore;
  resizeCanvas();
})();

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

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

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

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

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

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

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

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

もっと学びたい人へ

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

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、体験レッスンにぜひ来てみてください。