ディフェンスゲーム制作

CanvasとJavaScriptを使いゲームを完成させます

今回はシンプルなディフェンスゲーム制作を通じて、ゲームプログラムの基本的な処理を学んでいきます

まずは実際にプレイしてみましょう!

ゲームページはこちら

Webブラウザ上で動作するプログラムは、主に以下の3つの要素で構成されています。

HTML 構造の定義
画面の土台となる部品(キャンバス要素、スコア表示領域、操作用ボタンなど)を配置します。
CSS 視覚表現の設定
背景色、文字の書式、各部品のサイズやレイアウトなど、外観に関する指定を行います。
JavaScript 動的処理の実装
座標計算によるキャラクターの移動、オブジェクト同士の当たり判定、スコアの更新処理などを制御します。

ゲームの実行画面を構成する要素を定義します。動的な制御を行う <script> タグ内は、現段階では未記述(空の状態)とします。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    /* 全体のレイアウト設定 */
    body {
      margin: 0;
      overflow: hidden;
      background: #1a1a1a;
      font-family: sans-serif;
    }
    canvas {
      display: block;
    }
    /* UI要素(スコア・メッセージ)の配置設定 */
    #ui {
      position: absolute;
      top: 20px;
      left: 20px;
      color: white;
      pointer-events: none;
    }
    #msg {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      text-align: center; display: none;
    }
    button { 
      pointer-events: auto;
      padding: 10px 20px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div id="ui">
      <div>Score: <span id="score">0</span></div>
      <div>Bullet Size: <span id="bulletSize">5</span></div>
      <div>Bullet Speed: <span id="bulletSpeed">5</span></div>
  </div>

  <div id="msg">
    <h1>GAME OVER</h1>
    <button id="resetButton">Reset</button>
  </div>

  <canvas id="gameCanvas"></canvas>

  <script>
    // 今後のステップでロジックを実装します
  </script>
</body>
</html>

<canvas> は、JavaScriptを用いて動的にグラフィックスを描画するためのHTML要素です。Webゲーム開発における描画処理の標準規格として広く利用されています。

主な特徴と座標系

  • 座標管理: 左上端を原点 (0, 0) とし、右方向へ X軸、下方向へ Y軸が増加します。
  • 描画機能: 図形(矩形・円)、画像、テキストなどのレンダリングが可能です。
  • リアルタイム性: 高速な書き換えが可能で、アニメーションの実装に適しています。

Canvas座標系のイメージ

JavaScriptからCanvasを操作するには、2つの手順を実行します。まず、HTMLで定義した要素をプログラム内で取得します。次に、その要素から2Dグラフィックスを描画するためのAPI(コンテキスト)を呼び出します。

JavaScript

// IDを指定してCanvas要素を取得
const canvas = document.getElementById('gameCanvas');

// 2D描画用API(コンテキスト)を取得
const ctx = canvas.getContext('2d');

ゲームの状態を保持する変数と、プログラム全体で使用する固定値を定義します。 スコアや進行状況を管理する数値、複数のオブジェクトを格納するための配列、および動作の基準となる定数値を設定します。

JavaScript

// ゲームの進行状況を管理する変数
let score = 0;
let gameOver = false;
let enemies = [];
let bullets = [];
let mouse = { x: 0, y: 0 };

// 描画および計算に使用する定数
const PLAYER_RADIUS = 30; // プレイヤーの半径
const ENEMY_SPEED = 0.5;   // 敵の移動速度
const BULLET_RADIUS = 5;  // 弾の半径
const BULLET_SPEED = 5;   // 弾の移動速度

JavaScriptにおけるconstとletの決定的な違いは、**「一度入れた値を、後から入れ直せるかどうか」**という点です。

1. const(定数)
「定まった数」という名前の通り、一度値を決めたら変更できません。
再代入が禁止: const name = "田中"; と決めた後に name = "佐藤"; と書き換えることはできず、エラーになります。
使いどころ: プログラムの中で途中で変わってほしくない値や、名前、設定値などに使います。

2. let(変数)
「変化する数」として、後から何度でも値を入れ直すことができます。
再代入が可能: let score = 0; としておき、後から score = 100; と書き換えることができます。
使いどころ: 計算の途中で合計値が変わる場合や、繰り返しの回数を数えるときなど、値が変わる必要がある場合に使います。

ブラウザのサイズが変更されたとき、キャンバスの大きさも自動で調整されるようにします。また、マウスの動きとクリックを感知する設定も追加します。

JavaScript

function resize() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
window.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; });
window.addEventListener('mousedown', spawnBullet);
resize();

マウス座標と画面中心座標から逆正接関数(Math.atan2)を用いて射出角度を算出します。 算出した角度に基づき、弾の初期位置と速度成分(x, y)をオブジェクトとして定義し、bullets 配列に追加します。 これにより、画面上の複数の弾を配列の要素として個別に制御します。

射出角度と速度の計算イメージ

JavaScript / spawnBullet関数

function spawnBullet() {
  if (gameOver) return;

  // 中心点からマウスへの角度を算出
  const angle = Math.atan2(mouse.y - canvas.height / 2, mouse.x - canvas.width / 2);

  // 新規オブジェクトを配列に追加
  bullets.push({
    x: canvas.width / 2 + Math.cos(angle) * PLAYER_RADIUS, // 出現位置x
    y: canvas.height / 2 + Math.sin(angle) * PLAYER_RADIUS, // 出現位置y
    vx: Math.cos(angle) * BULLET_SPEED,                    // x軸の速度
    vy: Math.sin(angle) * BULLET_SPEED                     // y軸の速度
  });
}

requestAnimationFrame を用いて animate 関数を再帰的に実行し、画面を逐次更新します。 この処理では、直前のフレームの消去、マウス方向への角度計算と線画(砲身)、および配列内に存在する各弾の移動計算と描画を連続して行います。

JavaScript / 描画および更新処理

function animate() {
  if (gameOver) return;
  // 次のフレームの描画を予約
  requestAnimationFrame(animate);
  
  // 画面の消去(半透明の塗りで残像効果を付与)
  ctx.fillStyle = 'rgba(26, 26, 26, 0.3)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const cx = canvas.width / 2;
  const cy = canvas.height / 2;

  // マウスの方向に合わせた砲身の描画
  const angle = Math.atan2(mouse.y - cy, mouse.x - cx);
  ctx.beginPath();
  ctx.moveTo(cx, cy);
  ctx.lineTo(cx + Math.cos(angle) * 45, cy + Math.sin(angle) * 45);
  ctx.strokeStyle = '#3273dc';
  ctx.stroke();

  // 各弾の移動更新と描画
  bullets.forEach((b, bi) => {
    b.x += b.vx; // 速度成分を加算して位置を更新
    b.y += b.vy;
    
    ctx.beginPath();
    ctx.arc(b.x, b.y, BULLET_RADIUS, 0, Math.PI * 2);
    ctx.fillStyle = '#ffcc00';
    ctx.fill();

    // 画面外に出た弾を配列から削除
    if (b.x < 0 || b.x > canvas.width || b.y < 0 || b.y > canvas.height) {
      bullets.splice(bi, 1);
    }
  });
}

ゲームを最初から始めるために、スコアや配列の中身を空に戻します。animate() を実行することで、描画ループが開始されます。

function resetGame() {
  score = 0;
  enemies = [];
  bullets = [];
  gameOver = false;
  document.getElementById('score').textContent = score;
  document.getElementById('msg').style.display = 'none';
  
  animate();
}

resetGame()
document.getElementById('resetButton').addEventListener('click', resetGame)

なぜここで spawnEnemy を呼ぶのか?

resetGame は「ゲームの開始地点」です。ここで一度呼ぶことで、敵の生成という「終わりのないループ(タイマー)」に火をつけます。一度動き出せば、関数の中で自分自身をまた呼び出す(再帰)ため、二度呼ぶ必要はありません。

実行してみよう!デバッグタイム

デバッグをしてみましょう!思い通りに動かない場合は、以下のステップでデバッグしましょう。

  • コンソールを確認 F12キーで開発者ツールを開き、Consoleタブに赤いエラーが出ていないか?
  • スペルミス canvascanvas になっているか? ctxcontext になっていないか?
  • カッコの対応 { に対応する } が抜けていないか?

敵を追加するために必要な理論を整理します。

  • Math.random(): 0以上1未満のランダムな数字を作ります。これに出現させたい範囲を掛け算して、敵の位置をバラバラにします。
  • setTimeout(関数, 時間): 指定したミリ秒(1秒=1000)後に一度だけ関数を実行します。

画面外のランダムな位置に敵データを生成し、配列に追加します。最後に自分自身をまたタイマーにかけることで、敵が無限に出現し続ける仕組みを作ります。

function spawnEnemy() {
  if (gameOver) return; // ゲームオーバーなら次を予約しない

  const angle = Math.random() * Math.PI * 2; // 360度ランダムな方向
  const dist = Math.max(canvas.width, canvas.height); // 画面の外側

  enemies.push({
    x: canvas.width / 2 + Math.cos(angle) * dist,
    y: canvas.height / 2 + Math.sin(angle) * dist,
    r: 10 + Math.random() * 10 // 大きさもランダム
  });

  // 次の敵が出るまでの時間(スコアが上がると早くなる)
  const nextTime = Math.max(500, 2000 - score * 10);
  setTimeout(spawnEnemy, nextTime);
}

さらに、リセット時にも実行されるようにresetGame()に追記します。

function resetGame() {
  score = 0;
  enemies = [];
  bullets = [];
  gameOver = false;
  document.getElementById('score').textContent = score;
  document.getElementById('msg').style.display = 'none';
  
  spawnEnemy(); // ★追加: ゲーム開始時に敵の生成ループを開始
  animate();
}

resetGame()

「当たり」をどう判定するか? Canvasの世界では円の衝突は数学で解けます。

判定方法2つの円の中心点の距離が、それぞれの「半径の合計」よりも短くなれば、それは「重なっている(衝突している)」ということです。

まず3パターン考えます。①はなれている、②くっついている、③重なっている


次に、円の半径を使ってそれらを表現してみます。


①はなれている  →それぞれの円の半径を足した長さより、点と点の間の長さの方が「長い」状態


②くっついている →それぞれの円の半径を足した長さと、点と点の間の長さが「全く同じ」状態


③重なっている  →それぞれの円の半径を足した長さより、点と点の間の長さの方が「短い」状態


ということになります。


シミュレーターページはこちら

animate 関数内の bullets.forEach のすぐ下に、敵の更新と衝突判定のコードを追加します。

// animate関数内に追加する処理
enemies.forEach((e, ei) => {
  const dx = canvas.width / 2 - e.x;
  const dy = canvas.height / 2 - e.y;
  const dist = Math.sqrt(dx * dx + dy * dy);

  // 中心に向かって移動
  e.x += (dx / dist) * ENEMY_SPEED;
  e.y += (dy / dist) * ENEMY_SPEED;

  // 敵の描画
  ctx.beginPath();
  ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2);
  ctx.fillStyle = '#ff4444';
  ctx.fill();

  // プレイヤー(拠点)との衝突
  if (dist < PLAYER_RADIUS + e.r) {
    gameOver = true;
    document.getElementById('msg').style.display = 'block';
  }

  // 弾との衝突判定
  bullets.forEach((b, bi) => {
    const bdx = b.x - e.x;
    const bdy = b.y - e.y;
    const b_dist = Math.sqrt(bdx * bdx + bdy * bdy);
    if (b_dist < BULLET_RADIUS + e.r) {
      enemies.splice(ei, 1);
      bullets.splice(bi, 1);
      score += 10;
      document.getElementById('score').textContent = score;
    }
  });
});

実行してみよう!デバッグタイム

デバッグをしてみましょう!思い通りに動かない場合は、以下のステップでデバッグしましょう。

  • コンソールを確認 F12キーで開発者ツールを開き、Consoleタブに赤いエラーが出ていないか?
  • スペルミス canvascanvas になっているか? ctxcontext になっていないか?
  • カッコの対応 { に対応する } が抜けていないか?

Scriptタグ内の最終的な完成形を示します。自分のコードと見比べてうまく動作しない箇所を見つけて修正して完成させましょう

// scriptタグ内のJavaScript全文
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');

    let score = 0;
    let gameOver = false;
    let enemies = [];
    let bullets = [];
    let mouse = { x: 0, y: 0 };

    const PLAYER_RADIUS = 30;
    const ENEMY_SPEED = 0.5;
    const BULLET_RADIUS = 30;
    const BULLET_SPEED = 5;

    function resize() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }

    window.addEventListener('resize', resize);
    window.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; });
    window.addEventListener('mousedown', spawnBullet);
    resize();

    function spawnBullet() {
      if (gameOver) return;
      const angle = Math.atan2(mouse.y - canvas.height / 2, mouse.x - canvas.width / 2);
      bullets.push({
        x: canvas.width / 2 + Math.cos(angle) * PLAYER_RADIUS,
        y: canvas.height / 2 + Math.sin(angle) * PLAYER_RADIUS,
        vx: Math.cos(angle) * BULLET_SPEED,
        vy: Math.sin(angle) * BULLET_SPEED
      });
    }

    function spawnEnemy() {
      if (gameOver) return;
      const angle = Math.random() * Math.PI * 2;
      const dist = Math.max(canvas.width, canvas.height);
      enemies.push({
        x: canvas.width / 2 + Math.cos(angle) * dist,
        y: canvas.height / 2 + Math.sin(angle) * dist,
        r: 10 + Math.random() * 10
      });
      setTimeout(spawnEnemy, Math.max(500, 2000 - score * 10));
    }

    function animate() {
      if (gameOver) return;
      requestAnimationFrame(animate);
      ctx.fillStyle = 'rgba(26, 26, 26, 0.3)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      const cx = canvas.width / 2;
      const cy = canvas.height / 2;

      // 拠点(中央の円)
      ctx.beginPath();
      ctx.arc(cx, cy, PLAYER_RADIUS, 0, Math.PI * 2);
      ctx.strokeStyle = '#00ffcc';
      ctx.lineWidth = 3;
      ctx.stroke();

      // 砲台
      const angle = Math.atan2(mouse.y - cy, mouse.x - cx);
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx + Math.cos(angle) * 45, cy + Math.sin(angle) * 45);
      ctx.strokeStyle = '#ff3366';
      ctx.stroke();

      // 弾の更新
      bullets.forEach((b, bi) => {
        b.x += b.vx;
        b.y += b.vy;
        ctx.beginPath();
        ctx.arc(b.x, b.y, BULLET_RADIUS, 0, Math.PI * 2);
        ctx.fillStyle = '#ffcc00';
        ctx.fill();
        if (b.x < 0 || b.x > canvas.width || b.y < 0 || b.y > canvas.height) bullets.splice(bi, 1);
      });

      // 敵の更新
      enemies.forEach((e, ei) => {
        const dx = cx - e.x;
        const dy = cy - e.y;
        const dist = Math.sqrt(dx * dx + dy * dy);

        e.x += (dx / dist) * ENEMY_SPEED;
        e.y += (dy / dist) * ENEMY_SPEED;

        ctx.beginPath();
        ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2);
        ctx.fillStyle = '#ff4444';
        ctx.fill();

        // 衝突判定(拠点)
        if (dist < PLAYER_RADIUS + e.r) {
          gameOver = true;
          document.getElementById('msg').style.display = 'block';
        }

        // 衝突判定(弾)
        bullets.forEach((b, bi) => {
          const bdx = b.x - e.x;
          const bdy = b.y - e.y;
          const b_dist = Math.sqrt(bdx * bdx + bdy * bdy);
          if (b_dist < BULLET_RADIUS + e.r) {
            enemies.splice(ei, 1);
            bullets.splice(bi, 1);
            score += 10;
            document.getElementById('score').textContent = score;
          }
        });
      });
    }


    function resetGame() {
      score = 0;
      enemies = [];
      bullets = [];
      gameOver = false;
      document.getElementById('score').textContent = score;
      document.getElementById('msg').style.display = 'none';
      spawnEnemy();
      animate();
    }

    resetGame()
    document.getElementById('resetButton').addEventListener('click', resetGame)

定義部分の数字を変更したり、各箇所の色を変更してオリジナリティのある作品にしあげよう!

まだ変数化されていないパラメータ(大きさや色、速度など)があれば変数化して自由に変更できるようにしてみよう!

// 定義の部分
    let score = 0;
    let gameOver = false;
    let enemies = [];
    let bullets = [];
    let mouse = { x: 0, y: 0 };

    const PLAYER_RADIUS = 30;
    const ENEMY_SPEED = 0.5;
    const BULLET_RADIUS = 30;
    const BULLET_SPEED = 5;
アレンジ例(大きさ・速度などを変更)