JavaScriptでアンダーテイル風の弾幕バトルをゼロから作ろう!

JavaScriptでアンダーテイル風の弾幕バトルをゼロから作ろう!

HTML・CSS・JavaScriptだけで、ブラウザで動くアンダーテイル風のボスバトル「骨の雨を生き延びろ」を完成させるチュートリアルです。
骨の弾幕をよけるバトル、赤ソウル/青ソウルの切り替え、Canvasで描く骸骨ボスまで、全部自分の手で作って本格的なゲームに仕上げよう。

⏱ 読了:約30分 🎯 対象:プログラミング初級者〜中級者

📋 もくじ

  1. 完成するとどうなる?
  2. 準備するもの
  3. ファイル構成を作ろう
  4. HTMLを書こう
  5. CSSでデザインしよう
  6. JavaScriptの設計を決めよう
  7. ゲームの状態を管理しよう
  8. ハートを描こう
  9. 骨とボスを描こう
  10. バトルボックスとUIを描こう
  11. キー入力とハートの動き
  12. 骨の雨を降らせよう
  13. 当たり判定と演出
  14. ゲームループで全部つなげる
  15. BGMを鳴らそう
  16. 完成コード全文
  17. 動かしてみよう
  18. 次のステップ

完成するとどうなる?

この記事を最後まで読むと、ブラウザ上で遊べるアンダーテイル風のボスバトルが完成します。ただ弾をよけるだけじゃなくて、こんな機能がついています:

  • 上から骨が降ってくる弾幕システム(フェーズ1「ほねのあめ」)
  • 途中でボスが本気を出し、青ソウルモードに切り替わる(重力+ジャンプ操作!)
  • ベジェ曲線で描くハート(プレイヤー)と、Canvasだけで描く骸骨ボス
  • ボスは呼吸でゆらゆら動き、腕を振って骨を投げ、本気になると左目が青く光る
  • 被弾すると画面シェイク無敵時間の点滅
  • 「* ほんきを だす」と1文字ずつ表示されるセリフ演出
  • 25秒生き残れば勝ち。やられても弾速が上がって再挑戦できるリトライシステム
  • HPバー、残り時間、TRY回数の表示
  • BGMのループ再生

使うのはHTML・CSS・JavaScriptの3つだけ。画像ファイルは一切なしで、ハートも骨もボスもぜんぶJavaScriptで描きます

テトリスやレーシングゲームのチュートリアルでゲームループに慣れた人はもちろん、「キャラクターをコードで描く」「ゲームの場面を切り替える」という2つの新しいテーマを学べるので、Canvasゲーム2作目・3作目の題材にもぴったりです。

準備するもの

以下の2つだけ用意してください。

  • テキストエディタ(VS CodeやAntigravityがおすすめ)
  • Webブラウザ(Chrome、Edge、Safariなど何でもOK)

BGMを鳴らしたい人は、好きなmp3ファイルを1つ用意しておきましょう(フリーBGMサイトからダウンロードしてもOK。なくてもゲームは動きます)。

ファイル構成を作ろう

パソコンのどこかに undertale という名前のフォルダを作って、その中にファイルを用意します。

undertale/
    index.html ─ 画面の構造
    style.css  ─ 見た目のデザイン
    script.js  ─ ゲーム本体(全部の動き)
    bgm.mp3    ─ BGM(お好みで)

テトリスやレーシングのときと同じ「HTML・CSS・JS の3点セット」です。どんなゲームを作るときもこの構成は変わりません。

HTMLを書こう

index.html を開いて、以下のコードを書いてください。今回のHTMLは驚くほど短いです。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>骨の雨を生き延びろ|第1話 アンダーテイル風バトル</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <canvas id="cv" width="640" height="360"></canvas>
    <audio id="bgm" src="bgm.mp3" loop preload="auto"></audio>
    <script src="script.js"></script>
  </body>
</html>

HTMLのポイント解説

主役は <canvas id="cv">640×360ピクセルの画用紙に、JavaScriptがボスも骨もハートも毎フレーム描き直していきます。

レーシングゲームではHUDをHTML要素で重ねましたが、今回はHPバーもセリフ枠も全部Canvasに直接描きます。アンダーテイル風のドット感あるUIは、Canvasの fillTextfillRect で描いたほうが雰囲気が出るからです。同じ「画面に文字を出す」でも、作るゲームによってベストな方法は変わる、というのは覚えておきたいポイント。

<audio id="bgm"> はBGM用。loop を付けると曲が終わっても自動で最初に戻ります。

CSSでデザインしよう

style.css はたったこれだけです。

body {
  background: #0a0a0c;
  display: grid;
  place-items: center;
  height: 100vh;
  margin: 0;
}
canvas {
  background: #000;
  border-radius: 6px;
  image-rendering: pixelated;
}

CSSのポイント解説

display: gridplace-items: center の2行は、要素を画面のド真ん中に置くいちばん短い書き方。縦も横も一発で中央ぞろえになります。

image-rendering: pixelated は隠し味。Canvasが拡大されたときにぼやけずカクカクのドット感を保つ指定で、レトロゲームの雰囲気づくりに効きます。

JavaScriptの設計を決めよう

ここからが本番です! script.js は全体で650行ほど。書き始める前に設計を決めましょう。

このゲームの作りは、大きく3つに分かれています。

① 描く    … 絵をキャンバスに表示する
② 動かす  … ハートや骨を毎フレーム動かす
③ 判定する … ハートと骨がぶつかったか調べる

ファイル全体の流れはこうです。

1. 準備          ── キャンバス取得と、ゲーム全体で使う数字
2. ゲームの状態  ── 今どの場面か、HPは、弾はどこに…
3. ① 描く       ── ハート・骨・ボス・ボックス・UI
4. ② 動かす     ── 弾の発射、ハートの移動、フェーズ切替
5. ③ 判定する   ── 円どうしの距離で当たり判定
6. メインループ  ── 場面ごとに描く内容を切り替える
7. BGM          ── 最初の操作で再生開始
8. キー入力      ── 押されているキーを記録する

まずは「準備」から。ゲームの遊び心地を決める数字を、定数として最初にまとめておきます。

const canvas = document.getElementById("cv");
const ctx = canvas.getContext("2d"); // ctx に対して命令すると絵が描ける

const CANVAS_WIDTH = 640;  // 画面の横幅(px)
const CANVAS_HEIGHT = 360; // 画面の高さ(px)

// バトルボックス(ハートが動ける四角い枠)の位置と大きさ
const BOX = { x: 190, y: 100, w: 260, h: 175 };

const GAME_TIME = 25;        // この秒数だけ生き残れば勝ち
const MAX_HP = 20;           // ハートの最大HP
const PLAYER_SPEED = 170;    // ハートの移動の速さ(px/秒)
const GRAVITY = 300;         // 青ソウルのとき下に引っぱられる強さ
const JUMP_SPEED = 260;      // ジャンプした瞬間の上向きの速さ
const INVINCIBLE_TIME = 0.8; // 被弾後、無敵になる時間(秒)
const FULL_CIRCLE = Math.PI * 2; // 「まるごと1周(円)」を表す角度

定数をまとめておくメリット

レーシングゲームのCONFIGと同じ発想です。あとから数字を1つ変えるだけで遊び心地がガラッと変わります。

  • GAME_TIME を 60 → 長期戦の耐久バトルに
  • PLAYER_SPEED を 250 → 高速ハートで簡単モードに
  • GRAVITY を 500 → 青ソウルがキビキビ落ちる本家っぽい挙動に
  • MAX_HP を 1 → 一発アウトの緊張感MAXモードに

ゲームの状態を管理しよう

このゲームには「タイトル」「戦闘中」「セリフ表示」「ゲームオーバー」「勝利」の5つの場面があります。今どの場面かを変数 state に1つだけ持たせます。

// title=タイトル / play=戦闘中 / dialog=セリフ表示 / dead=やられた / win=勝った
let state = "title";

let heart;          // プレイヤーのハート(位置・速度・色)
let bullets;        // 画面上にある骨(弾)の配列
let hp;             // 今のHP
let elapsedTime;    // 戦闘が始まってからの経過時間(秒)
let phase;          // 1=ほねのあめ / 2=本気(青ソウル)
let invincibleTime; // 残りの無敵時間
let shakeTime;      // 残りの画面シェイク時間
let dialogTime;     // セリフを何秒表示しているか

let tryCount = 1;          // 何回目の挑戦か
let bulletSpeedRate = 1.0; // 弾速の倍率。やられるたびに増えていく

const keys = {}; // 今どのキーが押されているか(例: keys.ArrowLeft)

💡 「ステートマシン」はゲームの背骨

state の値によって、描くものも、キーの意味も変わります。たとえばSPACEキーは、タイトルでは「スタート」、ゲームオーバーでは「リトライ」、勝利画面では「再戦」。この作り方を**ステートマシン(状態機械)**と呼びます。RPGでもアクションでも、ほとんどのゲームはこの仕組みで場面を切り替えています。

もうひとつ大事なのが reset() 関数。スタート時とリトライ時にゲームを最初の状態に戻す処理を1か所にまとめておきます。

function reset() {
  heart = {
    x: CANVAS_WIDTH / 2, // 横は画面の真ん中
    y: BOX.y + 130,      // 縦はボックスの下のほう
    vy: 0,               // 縦方向の速度(ジャンプ用)
    blue: false,         // false=赤ソウル / true=青ソウル
  };
  bullets = [];
  hp = MAX_HP;
  elapsedTime = 0;
  phase = 1;
  invincibleTime = 0;
  shakeTime = 0;
  dialogTime = 0;
  spawnTimer = 0;
}

リセット処理を関数にしておかないと、「リトライしたのにHPが減ったまま」「前回の骨が残ってる」みたいなバグが必ず出ます。初期化は1か所に集める、これも鉄則です。

ハートを描こう

プレイヤーの分身、ハートを描きます。画像は使わず、ベジェ曲線2本で描きます。

function drawHeart(x, y, blue, size = 9) {
  ctx.save();
  ctx.translate(x, y); // これ以降は (x, y) を原点として描く
  ctx.fillStyle = blue ? "#4ca6ff" : "#ff2a2a"; // 青ソウルなら水色、赤ソウルなら赤

  // 無敵時間中はチカチカ点滅させる(半透明にする)
  if (invincibleTime > 0 && Math.floor(invincibleTime * 12) % 2 === 1) {
    ctx.globalAlpha = 0.35;
  }

  // ベジェ曲線2本でハートの形を描く
  ctx.beginPath();
  ctx.moveTo(0, size * 0.9);
  ctx.bezierCurveTo(-size * 1.5, -size * 0.3, -size * 0.7, -size * 1.2, 0, -size * 0.35);
  ctx.bezierCurveTo(size * 0.7, -size * 1.2, size * 1.5, -size * 0.3, 0, size * 0.9);
  ctx.fill();
  ctx.restore();
}

💡 translate + save/restore は「描く前の準備」セット

ctx.translate(x, y) を呼ぶと、それ以降の描画は (x, y) を原点(0, 0)とみなして行われます。ハートの形は「原点まわりの座標」で1回書けばよくて、位置が変わっても数字を書き直す必要がありません。

そして ctx.save()ctx.restore() で「座標の移動」や「透明度の変更」をなかったことにできます。translateやrotateを使うときは必ずsave/restoreで挟む、と覚えておけばOK。

💡 点滅は「時間を整数にして偶数・奇数で分ける」

無敵中の点滅は Math.floor(invincibleTime * 12) % 2 === 1 という1行で作っています。残り時間を12倍して整数にすると、1/12秒ごとに偶数と奇数が入れ替わる。奇数のときだけ半透明にすれば、チカチカ点滅の完成です。タイマー1つで点滅が作れる、いろんなゲームで使える小技です。

骨とボスを描こう

骨(弾)を描く

骨は「細長い四角+両端に丸いコブ4つ」で描きます。

function drawBone(bone) {
  ctx.save();
  ctx.translate(bone.x, bone.y);
  ctx.rotate(bone.rot || 0);

  ctx.fillStyle = "#fff";
  ctx.fillRect(-2.5, -bone.len / 2, 5, bone.len); // 骨の本体(細長い四角)

  // 両端にある丸いコブを4つ描く(上端と下端 × 左と右)
  for (const end of [-1, 1]) {
    for (const side of [-1, 1]) {
      ctx.beginPath();
      ctx.arc(side * 2.5, (end * bone.len) / 2, 4, 0, FULL_CIRCLE);
      ctx.fill();
    }
  }
  ctx.restore();
}

二重の for...of[-1, 1] × [-1, 1] の4通りの組み合わせを作り、コブを4つ打つのがポイント。符号(+1と-1)を座標に掛けると上下左右対称の図形が一気に描けます

骸骨ボスを描く

ボスは今回いちばんの大物ですが、分解すればぜんぶ基本図形の組み合わせです。

胴体(パーカー) … 角丸四角形
シャツ           … 三角形
腕               … 太い線(lineCap: "round")
頭蓋骨           … 楕円(ellipse)
目               … 黒い楕円 + 白い点
ニヤリ口         … 円弧(arc)+ 歯の縦線5本

全コードは完成版に譲って、ここでは「ボスが生きて見える」3つの工夫だけ紹介します。

工夫1:呼吸でゆらゆら

const breath = Math.sin(bossTime * 2.2) * 3; // 呼吸でゆらゆら上下する量
const headY = 62 + breath;                   // 頭の中心の高さ

Math.sin(時間) は-1〜+1をなめらかに行き来する波を作ります。これを頭の高さに足すだけで、ボスが呼吸しているように上下します。ゲームの「生きてる感」の正体は、だいたいsin波です。

工夫2:mood(気分)で表情を切り替え

drawBoss(mood) は引数で表情が変わります。idle=ふつう、angry=本気(左目が青く光る)、sweat=冷や汗、dim=うす暗い。場面に応じて呼び分けるだけで、ボスに感情が宿ります。

本気モードの光る目は shadowBlur を使ったネオン表現です:

ctx.shadowColor = "#4cd7ff";
ctx.shadowBlur = 16;
ctx.fillStyle = "#4cd7ff";
ctx.arc(centerX - 13, headY - 4, 4.5 + Math.sin(bossTime * 10), 0, FULL_CIRCLE);

半径にも Math.sin を足しているので、目が脈打つように明滅します。

工夫3:骨を投げる腕のアニメーション

骨を発射した瞬間に castTimer をセットし、その値が残っている間だけ腕の角度を変えます。

const swingAmount = Math.min(1, castTimer / CAST_DURATION);
const swing = -0.95 + castDirection * 0.38 * swingAmount;

弾の発射と腕の振りが同じタイマーで連動しているので、「ボスが骨を振ったから弾が飛んでくる」ように見えます。演出とゲームロジックをつなぐと説得力が一気に増す、いい例です。

バトルボックスとUIを描こう

バトルボックス

アンダーテイルといえばこの白枠。中を黒く塗ってからフチを描きます。

function drawBox() {
  ctx.fillStyle = "#000";
  ctx.fillRect(BOX.x, BOX.y, BOX.w, BOX.h); // 中を黒く塗ってボスを枠の後ろに隠す
  ctx.strokeStyle = "#fff";
  ctx.lineWidth = 4;
  ctx.strokeRect(BOX.x, BOX.y, BOX.w, BOX.h); // 白い枠線
}

先に描いたボスの下半身が、黒塗りで枠の後ろに隠れるのがミソ。Canvasは「あとから描いたものが上に重なる」ので、描く順番(ボス → ボックス → 骨 → ハート)そのものがレイヤー構造になっています。描く順番=重なり順です。

HPバー

ctx.fillStyle = "#7a1f1f"; // 背景の暗い赤バー
ctx.fillRect(barX, barY, barWidth, barHeight);

ctx.fillStyle = hp <= 4 ? "#ff5555" : "#ffd23c"; // HPが少ないと赤く
ctx.fillRect(barX, barY, (barWidth * hp) / MAX_HP, barHeight); // 今のHP分だけ塗る

バーの長さは barWidth × hp ÷ MAX_HP割合を掛け算するだけでHPバーは作れます。HPが4以下になると色が赤に変わる、という条件式のひと工夫でハラハラ感が出ます。

1文字ずつ出るセリフ

「* ほんきを だす」の演出はこの2行が心臓部です。

const shownLength = Math.floor(dialogTime * 14); // 1秒で約14文字
ctx.fillText(text.slice(0, shownLength), 166, 44);

経過時間×14を文字数にして、slice文字列の先頭からその文字数だけ切り出して表示する。時間が進むほど表示される文字が増えるので、タイプライターのようにセリフが流れます。難しそうに見える演出ほど、中身は意外とシンプルです。

キー入力とハートの動き

キーの状態を覚えておく

レーシングゲームでも出てきた定番テクニックです。「押している間ずっと動く」を実現するには、キーの状態をオブジェクトに記録します。

addEventListener("keydown", (e) => {
  keys[e.key] = true; // 押されたキーを「押している」状態にする

  if (e.key === " ") {
    // SPACE は場面ごとに役割が変わる
    if (state === "title") {
      reset();
      state = "play"; // タイトル → ゲーム開始
    } else if (state === "dead") {
      tryCount++;
      bulletSpeedRate = Math.round((bulletSpeedRate + 0.15) * 100) / 100;
      reset();
      state = "play"; // やられた → リトライ(弾速UP)
    } else if (state === "win") {
      reset();
      state = "play"; // 勝った → もう一度
    }
    e.preventDefault(); // SPACEで画面がスクロールしないようにする
  }

  if (e.key === "h" || e.key === "H") showHitbox = !showHitbox;

  if (e.key.startsWith("Arrow")) e.preventDefault();
});

addEventListener("keyup", (e) => {
  keys[e.key] = false; // キーを離したら「押していない」状態に
});

e.preventDefault() を忘れると、矢印キーやSPACEでブラウザの画面ごとスクロールしてしまいます。Canvasゲームでは必須のおまじないです。

リトライのたびに bulletSpeedRate が0.15ずつ増える仕掛けにも注目。「死んで覚える」だけじゃなく「死ぬほど難しくなる」ことで、1回でクリアする緊張感が生まれます。

赤ソウルと青ソウル:2つの操作モード

このゲーム最大の見せ場です。同じ moveHeart 関数の中で、ソウルの色によって物理法則が変わります。

function moveHeart(deltaTime) {
  const moveStep = PLAYER_SPEED * deltaTime; // この1フレームで進む距離

  // 左右の移動は赤ソウルでも青ソウルでも共通
  if (keys.ArrowLeft) heart.x -= moveStep;
  if (keys.ArrowRight) heart.x += moveStep;

  if (!heart.blue) {
    // 赤ソウル: 上下にも自由に動ける
    if (keys.ArrowUp) heart.y -= moveStep;
    if (keys.ArrowDown) heart.y += moveStep;
  } else {
    // 青ソウル: 重力で下に落ち、上キーでジャンプする
    const ground = BOX.y + BOX.h - 12; // 地面の高さ
    heart.vy += GRAVITY * deltaTime;   // だんだん下向きに加速
    heart.y += heart.vy * deltaTime;
    if (heart.y >= ground) {
      // 地面に着いた
      heart.y = ground;
      heart.vy = 0;
      if (keys.ArrowUp) heart.vy = -JUMP_SPEED; // 着地中に上キーでジャンプ
    }
  }

  // バトルボックスの外に出ないように位置を制限する
  heart.x = Math.max(BOX.x + 12, Math.min(BOX.x + BOX.w - 12, heart.x));
  heart.y = Math.max(BOX.y + 12, Math.min(BOX.y + BOX.h - 12, heart.y));
}

💡 重力はたった2行で作れる

速度に重力を足す:  vy += GRAVITY × dt
位置に速度を足す:  y  += vy × dt

「速度がだんだん増える → 位置の変化もだんだん大きくなる」ので、自然な放物線の落下になります。マリオもそうやって落ちています。ジャンプは「着地しているときだけ、上向きの速度をポンと与える」だけ。2行の物理エンジン、ぜひ覚えて帰ってください。

× deltaTime を必ず付けるのはレーシングゲームと同じ基本ルール。これで速いPCでも遅いPCでも同じ速さで動きます。

最後の Math.max(最小, Math.min(最大, 値)) は、値を範囲内に収める定番の書き方(クランプといいます)。ハートがバトルボックスから出られないのはこの2行のおかげです。

骨の雨を降らせよう

弾の発射は「タイマーに時間をためて、一定量たまったら1発出す」方式です。

function spawnBullets(deltaTime) {
  const spawnInterval = phase === 1 ? 0.1 : 0.13; // 骨を出す間かく(秒)

  spawnTimer += deltaTime;
  if (spawnTimer < spawnInterval) return; // まだ時間がたっていなければ出さない
  spawnTimer = 0;

  castTimer = CAST_DURATION; // ボスが腕を振るアニメを開始
  castDirection *= -1;       // 振る向きを反対にする

  if (phase === 1) {
    // 技1「ほねのあめ」: 上からまっすぐ降ってくる
    bullets.push({
      x: BOX.x + 14 + Math.random() * (BOX.w - 28), // 横位置はランダム
      y: BOX.y + 8,
      vx: 0,
      vy: (90 + Math.random() * 70) * bulletSpeedRate, // 下向きの速さ
      len: 18 + Math.random() * 14,
    });
  } else {
    // 技2: ふだんは地面を左右に走る骨(ジャンプでよける)、たまに上から落ちる
    const fromGround = Math.random() < 0.75;
    if (fromGround) {
      const fromLeft = Math.random() < 0.5; // 左右どちらから来るか
      bullets.push({
        x: fromLeft ? BOX.x + 8 : BOX.x + BOX.w - 8,
        y: BOX.y + BOX.h - 16, // 地面の高さ
        vx: (fromLeft ? 1 : -1) * (110 + Math.random() * 60) * bulletSpeedRate,
        vy: 0,
        len: 26 + Math.random() * 12,
      });
    } else {
      bullets.push({
        x: BOX.x + 14 + Math.random() * (BOX.w - 28),
        y: BOX.y + 8,
        vx: 0,
        vy: 100 * bulletSpeedRate,
        len: 16,
      });
    }
  }
}

💡 弾は「オブジェクトの配列」で管理する

骨1本は { x, y, vx, vy, len } というただのオブジェクト。それを bullets 配列に push で追加し、毎フレーム全部を動かします。

for (const bullet of bullets) {
  bullet.x += bullet.vx * deltaTime;
  bullet.y += bullet.vy * deltaTime;
}

そして画面の外に出た骨は必ず消すこと。

bullets = bullets.filter(
  (bullet) =>
    bullet.y < BOX.y + BOX.h - 4 &&
    bullet.x > BOX.x + 4 &&
    bullet.x < BOX.x + BOX.w - 4,
);

filter は「条件に合うものだけ残した新しい配列」を作るメソッド。これを忘れると骨が無限に増え続けて、だんだんゲームが重くなります(メモリリークという有名なバグの一種です)。

フェーズ切り替え:ボスが本気を出す

戦闘時間が半分を過ぎたら、フェーズ2に切り替えます。

if (phase === 1 && elapsedTime >= GAME_TIME / 2) {
  phase = 2;
  heart.blue = true;   // 青ソウルに変える
  state = "dialog";    // セリフ画面へ
  dialogTime = 0;
  return;
}

state"dialog" にするだけで、次のフレームからメインループがセリフ画面を描き始めます。ステートマシンにしておいたおかげで、場面転換がたった1行。セリフを見せ終わったら弾を全消しして戦闘再開です。

当たり判定と演出

円どうしの距離で判定する

当たり判定は、ハートと骨をそれぞれ円とみなして、中心どうしの距離で決めます。

function checkHit() {
  if (invincibleTime > 0) return; // 無敵中はダメージを受けない

  for (const bullet of bullets) {
    // ハートと骨の中心どうしの距離を求める
    const dx = heart.x - bullet.x;
    const dy = heart.y - bullet.y;
    const distance = Math.sqrt(dx * dx + dy * dy);

    // 距離が「ハートの半径 + 骨の太さ」より近ければ当たり
    const hitRadius = 9 + bullet.len / 3;
    if (distance < hitRadius) {
      hp--;                             // HPを1減らす
      invincibleTime = INVINCIBLE_TIME; // しばらく無敵に
      shakeTime = 0.25;                 // 画面をゆらす
      if (hp <= 0) state = "dead";
      return; // 1フレームに1回ぶつかれば十分なので終了
    }
  }
}

距離の計算は中学で習う三平方の定理そのもの。√(dx² + dy²) で2点間の距離が出ます。「数学いつ使うの?」の答えがここにあります。

💡 無敵時間はゲームの優しさ

被弾した瞬間に invincibleTime をセットし、0より大きい間は判定そのものをスキップします。これがないと、骨に触れた瞬間に毎フレーム1ダメージ=一瞬でHP全損します。ほとんどのアクションゲームに無敵時間が入っているのは、この問題を防ぐためです。

画面シェイクは2行

if (shakeTime > 0) {
  ctx.translate((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10);
}

描画前に画面全体をランダムに数ピクセルずらすだけ。Math.random() - 0.5 は-0.5〜+0.5の乱数なので、上下左右にまんべんなく揺れます。たった2行なのにダメージの「痛み」が伝わる、コスパ最強の演出です。

デバッグ用:当たり判定の見える化

Hキーを押すと、ハートの当たり判定の円が緑色で表示されます。

if (showHitbox) {
  ctx.strokeStyle = "#46e07a";
  ctx.beginPath();
  ctx.arc(heart.x, heart.y, 9, 0, FULL_CIRCLE);
  ctx.stroke();
}

見た目のハートより判定が少し小さいことが分かるはず。見た目より判定を小さめにするとプレイヤーは「ギリギリよけた!」と気持ちよくなれる、というのは商業ゲームでも使われている調整テクニックです。

ゲームループで全部つなげる

いよいよ心臓部。requestAnimationFrame で毎秒約60回呼ばれるループの中で、state に応じて描く内容を切り替えます。

let lastTimestamp = 0;

function loop(timestamp) {
  // 前のフレームからの経過時間(秒)を求める。
  let elapsed = (timestamp - lastTimestamp) / 1000;
  if (!elapsed || elapsed < 0) elapsed = 0;
  const deltaTime = Math.min(0.033, elapsed); // 最大 1/30 秒に制限
  lastTimestamp = timestamp;

  bossTime += deltaTime;
  castTimer = Math.max(0, castTimer - deltaTime);

  ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // 前のフレームの絵を消す

  ctx.save();
  if (shakeTime > 0) {
    ctx.translate((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10);
  }

  if (state === "title") {
    drawBoss("idle");
    drawBox();
    centerText("骨の雨を", 160, 28, "#fff");
    centerText("生き延びろ", 198, 32, "#ff2a2a");
    centerText("SPACE でスタート / 矢印キーで操作", 250, 13, "#9aa0ac");
  } else if (state === "play") {
    update(deltaTime); // 先に動かしてから…
    drawBoss(phase === 2 ? "angry" : "idle");
    drawBox();
    bullets.forEach(drawBone);
    drawHeart(heart.x, heart.y, heart.blue);
    drawHP();
    drawUI();
  } else if (state === "dialog") {
    dialogTime += deltaTime;
    drawBoss("angry");
    drawBox();
    drawHeart(heart.x, heart.y, true);
    drawHP();
    drawUI();
    drawDialog("* ほんきを だす");
    if (dialogTime > 1.6) {
      bullets = [];
      state = "play"; // セリフを見せ終わったら戦闘再開
    }
  } else if (state === "dead") {
    drawBoss("dim");
    drawBox();
    centerText("GAME OVER", 170, 34, "#ff2a2a");
    centerText("生存タイム " + elapsedTime.toFixed(1) + " 秒", 204, 15, "#fff");
    centerText("SPACE でリトライ(弾速UP)", 246, 13, "#9aa0ac");
  } else if (state === "win") {
    drawBoss("sweat");
    drawBox();
    centerText("YOU WIN!", 172, 38, "#46e07a");
    centerText("TRY " + tryCount + " / あなたはボスを超えた", 210, 14, "#fff");
    centerText("SPACE で再戦", 248, 13, "#9aa0ac");
    drawHP();
  }

  ctx.restore();
  requestAnimationFrame(loop); // 次のフレームでまた loop を呼んでもらう
}

reset();
requestAnimationFrame(loop);

ゲームループのポイント解説

const deltaTime = Math.min(0.033, elapsed)重要なおまじない。ブラウザのタブを切り替えて戻ってきたとき、deltaTimeが巨大な値になって骨が一気にワープするのを防ぎます(レーシングゲームでも同じ処理をしましたね)。

場面ごとの描画がぜんぶ if (state === ...) で並んでいるので、「今なにが起きているか」がコードを上から読むだけで分かるのがこの構造のいいところ。ゲームオーバー画面でもボスを dim(うす暗く)で描き続けるなど、場面ごとの空気の違いは drawBoss(mood) の引数だけで表現しています。

BGMを鳴らそう

最後にBGMです。<audio> タグを使うので、コードはとても短い。

const bgm = document.getElementById("bgm");
bgm.volume = 0.5;

function startBGM() {
  // ブラウザは「ユーザーが操作する前」には音を鳴らせない決まりがある。
  if (bgm.paused) {
    bgm.play().catch(() => {}); // 失敗してもエラーで止まらないように無視する
  }
}
addEventListener("keydown", startBGM, { once: true }); // once: 1回だけ実行
addEventListener("pointerdown", startBGM, { once: true });

💡 音はユーザー操作のあとでしか鳴らせない

レーシングゲームのWeb Audio APIでも出てきたルールです。ブラウザは勝手に音を鳴らすことを禁止しているので、最初のキー入力かクリックをきっかけに再生します。{ once: true } を付けると、そのイベントリスナーは1回実行されたら自動で削除されます。

.catch(() => {}) は、再生に失敗してもゲームが止まらないようにする保険。音はゲームのおまけ、失敗しても本体は動き続けるべき、という設計思想です。

完成コード全文

以下が完成コードです。そのままコピペしてOK! 動いたら、定数の数字をいじったり技を追加したりして遊んでみてください。

index.html(完成版)

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>骨の雨を生き延びろ|第1話 アンダーテイル風バトル</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <canvas id="cv" width="640" height="360"></canvas>
    <audio id="bgm" src="bgm.mp3" loop preload="auto"></audio>
    <script src="script.js"></script>
  </body>
</html>

style.css(完成版)

body {
  background: #0a0a0c;
  display: grid;
  place-items: center;
  height: 100vh;
  margin: 0;
}
canvas {
  background: #000;
  border-radius: 6px;
  image-rendering: pixelated;
}

script.js(完成版)

script.js は約650行の長さなので、ここではファイルのダウンロードリンクを置きます。(※公開時にダウンロードリンクを差し替えてください)

全行に日本語コメントが付いているので、ダウンロードして読みながら写経するのもおすすめです。

動かしてみよう

ファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。SPACEキーでバトルスタートです!

矢印キー:ハートを操作、SPACE:スタート/リトライ/再戦、H:当たり判定の表示切り替え

前半は上から降る「ほねのあめ」を上下左右によけ、後半は青ソウルになって地面を走る骨をジャンプでかわします。25秒生き残れば勝利!

もし動かない・エラーが出る場合は以下をチェック:

  • ファイルが同じフォルダに入っているか?
  • ファイル名は index.htmlstyle.cssscript.js か?
  • ブラウザの開発者ツール(F12)の Console タブにエラーが出ていないか?
  • BGMが鳴らない場合は、bgm.mp3 がフォルダにあるか? 一度キーを押したか?(ブラウザの仕様で、ユーザー操作なしには音が鳴らせません)

次のステップ

動いたら、自分なりに改造して遊んでみよう。**「数字を変える → 見た目を変える → 機能を増やす」**の順に少しずつ攻めるのがおすすめです。

🟢 簡単(定数をいじるだけ)

  • GAME_TIME を 60 にして耐久バトルに
  • MAX_HP を 1 にして一発アウトの鬼モードに
  • PLAYER_SPEED を上げて高速ハートに
  • GRAVITYJUMP_SPEED を変えてジャンプの気持ちよさを調整
  • BOX の大きさを変えて、狭い箱での密着バトルに

🟡 中くらい(見た目と技を変える)

  • ハートやソウルの色を自分の好きな色に変える
  • spawnBullets の間かくや弾速を変えて、自分好みの難易度に調整
  • ボスのセリフ「* ほんきを だす」を自分の言葉に書き換える
  • フェーズ2の「地面走り率」(Math.random() < 0.75 の数字)を変えて弾のパターンを変化させる
  • ボスのパーカーの色や表情を描き変えて、オリジナルボスにする

🔴 むずかしい(機能を追加する)

  • フェーズ3を追加して、回転する骨(rot を毎フレーム増やす)を降らせる
  • 回復アイテム(緑の弾)を混ぜて、取るとHPが回復するようにする
  • ボスにHPを付けて、こちらから攻撃するターンを追加する(本家の「たたかう」)
  • 骨の動きに Math.sin を使って、うねうね蛇行する弾を作る
  • ローカルストレージに最速クリアタイムを記録する

学べることが盛りだくさん

このゲーム1本で、こんなことが学べます:

  • Canvas APIでの図形描画(ベジェ曲線・楕円・円弧・角丸四角形)
  • ステートマシンによる場面管理
  • ゲームループと requestAnimationFrame、deltaTimeの扱い
  • 重力とジャンプの物理シミュレーション(たった2行!)
  • 三平方の定理を使った円どうしの当たり判定
  • 無敵時間・画面シェイク・タイプライター演出などのゲーム演出
  • 配列と filter による弾の管理(メモリリーク対策)
  • イベントリスナーとキー入力管理

わからないことがあったら「JavaScript Canvas ○○」で検索してみよう。今回覚えたステートマシンと当たり判定は、シューティングでもRPGでもアクションでも使うゲーム作りの共通言語。この骨バトルを改造しているうちに、次のゲームのアイデアがきっと浮かんでくるよ!

野澤嘉孝

この記事を書いた人

野澤 嘉孝

ソフトウェアエンジニア。同志社大学理工学部を経て、京都大学大学院で核融合発電の基礎研究(プラズマ物理)に従事。在学中は高校生向け数学塾の講師を、大学院では大学生に対して物理実験の授業を担当し、延べ500名以上の学生をサポート。現在は業界特化型SaaSの開発に携わりながら、中高生向けプログラミングスクール Sandbox(サンドボックス)の運営を行う。

"自分で作れた"が生まれるスクール、Sandbox

Sandboxについて詳しく見る > すぐに相談したい方は → 無料面談に申し込む まずは気軽に → Peatixで無料体験イベントを見る(オンライン・無料)

入会金無料・いつでも退会OK・退会後もいつでも再入会できます