HTML・CSS・JavaScriptだけで、ブラウザで動くアンダーテイル風のボスバトル「骨の雨を生き延びろ」を完成させるチュートリアルです。
骨の弾幕をよけるバトル、赤ソウル/青ソウルの切り替え、Canvasで描く骸骨ボスまで、全部自分の手で作って本格的なゲームに仕上げよう。
⏱ 読了:約30分 🎯 対象:プログラミング初級者〜中級者
📋 もくじ
- 完成するとどうなる?
- 準備するもの
- ファイル構成を作ろう
- HTMLを書こう
- CSSでデザインしよう
- JavaScriptの設計を決めよう
- ゲームの状態を管理しよう
- ハートを描こう
- 骨とボスを描こう
- バトルボックスとUIを描こう
- キー入力とハートの動き
- 骨の雨を降らせよう
- 当たり判定と演出
- ゲームループで全部つなげる
- BGMを鳴らそう
- 完成コード全文
- 動かしてみよう
- 次のステップ
完成するとどうなる?
この記事を最後まで読むと、ブラウザ上で遊べるアンダーテイル風のボスバトルが完成します。ただ弾をよけるだけじゃなくて、こんな機能がついています:
- 上から骨が降ってくる弾幕システム(フェーズ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の fillText や fillRect で描いたほうが雰囲気が出るからです。同じ「画面に文字を出す」でも、作るゲームによってベストな方法は変わる、というのは覚えておきたいポイント。
<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: grid と place-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.html、style.css、script.jsか? - ブラウザの開発者ツール(F12)の Console タブにエラーが出ていないか?
- BGMが鳴らない場合は、
bgm.mp3がフォルダにあるか? 一度キーを押したか?(ブラウザの仕様で、ユーザー操作なしには音が鳴らせません)
次のステップ
動いたら、自分なりに改造して遊んでみよう。**「数字を変える → 見た目を変える → 機能を増やす」**の順に少しずつ攻めるのがおすすめです。
🟢 簡単(定数をいじるだけ)
GAME_TIMEを 60 にして耐久バトルにMAX_HPを 1 にして一発アウトの鬼モードにPLAYER_SPEEDを上げて高速ハートにGRAVITYとJUMP_SPEEDを変えてジャンプの気持ちよさを調整BOXの大きさを変えて、狭い箱での密着バトルに
🟡 中くらい(見た目と技を変える)
- ハートやソウルの色を自分の好きな色に変える
spawnBulletsの間かくや弾速を変えて、自分好みの難易度に調整- ボスのセリフ「* ほんきを だす」を自分の言葉に書き換える
- フェーズ2の「地面走り率」(
Math.random() < 0.75の数字)を変えて弾のパターンを変化させる - ボスのパーカーの色や表情を描き変えて、オリジナルボスにする
🔴 むずかしい(機能を追加する)
- フェーズ3を追加して、回転する骨(
rotを毎フレーム増やす)を降らせる - 回復アイテム(緑の弾)を混ぜて、取るとHPが回復するようにする
- ボスにHPを付けて、こちらから攻撃するターンを追加する(本家の「たたかう」)
- 骨の動きに
Math.sinを使って、うねうね蛇行する弾を作る - ローカルストレージに最速クリアタイムを記録する
学べることが盛りだくさん
このゲーム1本で、こんなことが学べます:
- Canvas APIでの図形描画(ベジェ曲線・楕円・円弧・角丸四角形)
- ステートマシンによる場面管理
- ゲームループと
requestAnimationFrame、deltaTimeの扱い - 重力とジャンプの物理シミュレーション(たった2行!)
- 三平方の定理を使った円どうしの当たり判定
- 無敵時間・画面シェイク・タイプライター演出などのゲーム演出
- 配列と
filterによる弾の管理(メモリリーク対策) - イベントリスナーとキー入力管理
わからないことがあったら「JavaScript Canvas ○○」で検索してみよう。今回覚えたステートマシンと当たり判定は、シューティングでもRPGでもアクションでも使うゲーム作りの共通言語。この骨バトルを改造しているうちに、次のゲームのアイデアがきっと浮かんでくるよ!