はじめに
「ゲームを作りたいならUnityかUnreal Engineを使えばいい」――そう言われる時代に、あえて外部ライブラリもゲームエンジンも一切使わず、HTML5 Canvas とバニラJavaScriptだけでドラクエ風のターン制RPGをフルスクラッチで開発しました。
なぜそんな遠回りをしたのか。それは、ゲームエンジンが裏で何をやっているのかを理解したかったからです。
結果として、マップ探索、NPC会話、ランダムエンカウント、ターン制バトル、レベルアップ、装備・魔法システム、BGMクロスフェード、セーブ/ロード、スマホ対応まで、RPGに必要な機能を一通り実装できました。そしてその過程で、有限状態機械、固定タイムステップアルゴリズム、重み付き確率分布、視錐台カリング、画家のアルゴリズムといった計算機科学の基礎概念が、ゲーム開発のあらゆる場面に息づいていることを実感しました。
この記事では、実際のコードを交えながら、ゲームプログラミングの裏側にある理論と実践を解説します。
デモ(ブラウザで遊べます): https://nobu-suzuki345.github.io/shadow-cave/
ソースコード: https://github.com/nobu-suzuki345/shadow-cave
| 技術 | 詳細 |
|---|---|
| 描画 | HTML5 Canvas 2D API |
| ロジック | バニラ JavaScript (ES6+) |
| スタイル | CSS3 (Flexbox, メディアクエリ) |
| 音声 | HTML5 Audio API |
| データ保存 | localStorage |
| 外部ライブラリ | なし |
第1章:ゲームループの理論 ― なぜ setInterval ではダメなのか
ゲーム開発で最初に直面する問題が「ゲームループをどう書くか」です。素朴に考えれば setInterval(update, 16) で60FPSを実現できそうですが、これには致命的な問題があります。
setInterval の問題点:
- ブラウザのタブが非アクティブになると実行間隔が引き延ばされる
- 描画タイミングとディスプレイのリフレッシュレートが同期しない(ティアリングの原因)
- 処理落ちした場合にフレームが蓄積し、一気に実行される(スパイラル・オブ・デス)
本プロジェクトでは、ゲーム業界で標準的な固定タイムステップ + アキュムレータパターンを採用しました。これは Glenn Fiedler 氏の有名な記事 "Fix Your Timestep!" で解説されている手法です。
// game.js
loop(timestamp) {
if (!this.running) return;
const dt = timestamp - this.lastTime; // 前フレームからの経過時間(ms)
this.lastTime = timestamp;
this.accumulator += dt;
// ★ スパイラル・オブ・デス防止
// タブ復帰時に dt が巨大になっても、最大5フレーム分に制限する
if (this.accumulator > FRAME_TIME * 5) {
this.accumulator = FRAME_TIME * 5;
}
// ★ 固定タイムステップで更新
// 物理演算やゲームロジックは常に一定間隔(16.67ms)で実行される
while (this.accumulator >= FRAME_TIME) {
this.update();
Input.endFrame(); // 入力の「押した瞬間」をリセット
this.accumulator -= FRAME_TIME;
}
this.render(); // 描画は毎フレーム1回
requestAnimationFrame((t) => this.loop(t));
}
このアルゴリズムが解決すること
1. 決定論的なゲームロジック
update() は常に FRAME_TIME(16.67ms = 1/60秒)間隔で呼ばれます。60FPSのモニターでも144FPSのモニターでも、ゲーム内の時間の進み方は同じです。これはネットワーク対戦ゲームのリプレイ機能などでも必須の考え方です。
2. スパイラル・オブ・デスの防止
ユーザーが別タブに切り替えて5秒後に戻ってくると、dt = 5000ms となり、while ループが300回(5000 / 16.67)回ることになります。これを放置するとブラウザがフリーズします。accumulator に上限を設けることで、最大でも5フレーム分の処理に抑えています。
3. 更新と描画の分離
update() と render() を分離することで、描画がスキップされても論理状態は正しく保たれます。これは Model-View の分離と同じ設計原則です。
第2章:有限状態機械(FSM)― ゲームの「脳」を設計する
ゲーム開発で最も頻出するデザインパターンが**有限状態機械(Finite State Machine)**です。計算理論の教科書に出てくるあの概念が、まさにゲーム開発の中核にあります。
本プロジェクトでは、FSMが3つのレイヤーで使われています。
レイヤー1:シーン管理
ゲーム全体を「今どの画面にいるか」で管理します。
タイトル画面 → 探索シーン ⇄ バトルシーン
⇅ ↓
メニュー画面 ゲームオーバー
エンディング
// game.js - シーンの切替
changeScene(key, params) {
if (this.scenes[key]) {
this.activeScene = key;
this.scenes[key].enter(params || {}); // 新しいシーンの初期化
}
}
各シーンは enter() → update() → render() のライフサイクルを持ちます。これは GoF デザインパターンのState パターンそのものです。Game クラスは現在のシーンに処理を委譲するだけで、シーン固有のロジックを一切知りません。
さらに興味深いのはオーバーレイの仕組みです。
// game.js - update()
if (this.overlayScene && this.scenes[this.overlayScene]) {
this.scenes[this.overlayScene].update(); // オーバーレイが優先
} else if (this.activeScene && this.scenes[this.activeScene]) {
this.scenes[this.activeScene].update(); // なければメインシーン
}
メニューや会話ウィンドウは、探索シーンの「上に重ねる」オーバーレイとして動作します。探索シーンは一時停止したまま背景に残り、オーバーレイが閉じれば即座に復帰します。これはスタック型のシーン管理で、OSのウィンドウシステムと同じ発想です。
レイヤー2:バトルのステートマシン
バトルシーンの内部は、さらに細かいFSMで制御されています。
┌─────────────────────────────────────────────────┐
│ バトルFSM │
│ │
│ INIT → MESSAGE → PLAYER_COMMAND │
│ ├→ PLAYER_MAGIC → EXECUTE │
│ ├→ PLAYER_ITEM → EXECUTE │
│ └→ PLAYER_TARGET → EXECUTE │
│ ↓ │
│ ENEMY_TURN │
│ ↓ ↓ │
│ VICTORY DEFEAT │
│ ↑ │ │
│ PLAYER_COMMAND ←┘ ↓ │
│ GAMEOVER │
└─────────────────────────────────────────────────┘
// scene-battle.js
update() {
if (this.state === 'MESSAGE') { /* テキスト表示待ち */ }
if (this.state === 'PLAYER_COMMAND') { /* コマンド選択 */ }
if (this.state === 'PLAYER_MAGIC') { /* 魔法選択 */ }
if (this.state === 'EXECUTE') { /* 行動実行・ダメージ計算 */ }
if (this.state === 'ENEMY_TURN') { /* 敵AI行動 */ }
if (this.state === 'VICTORY') { /* 勝利処理・経験値 */ }
if (this.state === 'DEFEAT') { /* 敗北処理 */ }
}
状態遷移が明確に定義されているため、「魔法選択中に敵が攻撃してくる」といった不整合が構造的に発生しません。FSMはバグの温床になりがちな複雑なフローを、安全に管理するための強力なツールです。
レイヤー3:画面遷移エフェクト
シーン切替時のフェード演出も、実は小さなFSMです。
// game.js - 2フェーズのフェード遷移
transitionTo(key, params) {
if (this.transition.active) return; // 二重遷移を防止
this.transition.active = true;
this.transition.phase = 'out'; // フェーズ1: 暗転
this.transition.alpha = 0;
this.transition.callback = () => {
this.changeScene(key, params); // 完全に暗くなったらシーン切替
this.transition.phase = 'in'; // フェーズ2: 明転
};
}
状態は inactive → out(暗転中)→ in(明転中)→ inactive と遷移します。暗転が完了した瞬間にシーンが切り替わるため、プレイヤーには画面の切り替わりが見えません。映画のカット割りと同じ技法です。
第3章:確率とゲームバランスの数学
RPGのバトルは、突き詰めると確率分布に基づく数値シミュレーションです。本プロジェクトのダメージ計算式には、いくつかの数学的な工夫が込められています。
物理ダメージの計算式
// battle-actions.js
calcPhysicalDamage: function(attacker, defender) {
var base = Math.max(1, Math.floor(atk - def / 2));
var variance = Math.floor(base * 0.15);
var damage = base + Math.floor(Math.random() * (variance * 2 + 1)) - variance;
var critChance = (1 / 16) + luck * 0.005;
var isCrit = Math.random() < critChance;
if (isCrit) damage = Math.floor(damage * 1.5);
return { damage: Math.max(1, damage), critical: isCrit };
}
数式で表現すると以下のようになります。
$$
D_{base} = \max(1, \lfloor ATK - \frac{DEF}{2} \rfloor)
$$
$$
D_{final} = D_{base} \pm 15% \text{ の一様分布}
$$
$$
P(critical) = \frac{1}{16} + LUK \times 0.005
$$
なぜ ATK - DEF/2 なのか?
もし ATK - DEF だとしたら、攻撃力10 vs 防御力10 でダメージ0になってしまいます。防御力を半減して差し引くことで、攻撃側がある程度有利になり、戦闘が膠着しにくくなります。これはドラゴンクエストシリーズなどの古典的RPGでも採用されている考え方です。
±15% の分散はなぜ必要か?
毎回同じダメージだと戦闘が作業的になります。一様分布のランダム性を加えることで「今回は高いダメージが出た!」という小さな感動が生まれます。ただし分散が大きすぎるとプレイヤーが戦略を立てられなくなるため、±15%というのは絶妙なバランスです。
クリティカルヒットの確率設計
基本確率: 1/16 = 6.25%
LUK=10 のとき: 6.25% + 5% = 11.25%
LUK=20 のとき: 6.25% + 10% = 16.25%
運(LUK)ステータスに0.5%ずつ加算されます。これにより、LUKは「上げても体感しにくいが、確率的には確実に効いている」パラメータになります。プレイヤーの期待値に対して微妙に上振れするのが、RPGにおける「運」の正しい演出です。
魔法ダメージと属性弱点
calcMagicDamage: function(caster, target, spell) {
var base = Math.max(1, Math.floor(spell.power + mag - mdf / 2));
var variance = Math.floor(base * 0.1); // 魔法は ±10%(物理より安定)
// 弱点属性なら 1.5倍
if (spell.element && target.weakness === spell.element) {
damage = Math.floor(damage * 1.5);
}
return Math.max(1, damage);
}
物理ダメージの分散が ±15% なのに対し、魔法は ±10% です。「魔法は安定して高いダメージを出せる代わりにMPを消費する」という設計思想がここに表れています。さらに弱点属性ヒット時は1.5倍。ボスの「魔竜ダークドラゴン」は氷属性が弱点に設定されているため、プレイヤーにアイス魔法の戦略的使用を促す仕掛けになっています。
逃走確率 ― 速度差のロジスティック関数
calcFleeChance: function(partySpeed, enemySpeed) {
var chance = 0.5 + (partySpeed - enemySpeed) * 0.04;
return Math.min(0.9, Math.max(0.15, chance));
}
逃走確率は 50% + (速度差 × 4%) で計算され、15%~90% の範囲にクランプされます。
自分が10速く → 50% + 40% = 90%(上限) ほぼ確実に逃げられる
同速 → 50% 五分五分
自分が5遅い → 50% - 20% = 30% 逃げにくいが不可能ではない
下限が15%(0%ではない)のが重要です。どんなに強い敵でも「絶対逃げられない」ことはなく、プレイヤーに常に希望を残します(ただしボス戦では逃走自体が禁止されています)。
第4章:敵AIの重み付きランダム選択アルゴリズム
敵の行動選択には、**重み付きランダム選択(Weighted Random Selection)**が使われています。これはゲームAI、広告配信、ガチャシステムなど、幅広い分野で使われる基本的なアルゴリズムです。
// battle-actions.js
chooseEnemyAction: function(enemy) {
var totalWeight = 0;
for (var i = 0; i < actions.length; i++) {
totalWeight += actions[i].weight;
}
var roll = Math.random() * totalWeight; // 0 ~ totalWeight の乱数
for (var j = 0; j < actions.length; j++) {
roll -= actions[j].weight;
if (roll <= 0) return actions[j]; // 重みを消費しきったら選択
}
return actions[0];
}
アルゴリズムの図解
ボスドラゴンの行動テーブルを例に取ります。
// enemies.js
boss_dragon: {
actions: [
{ type: 'attack', weight: 0.4 }, // 物理攻撃
{ type: 'spell', spell: 'fire', weight: 0.3 }, // ファイア
{ type: 'special', name: 'ブレス攻撃', power: 35, weight: 0.3 } // ブレス
],
}
重みの合計: 0.4 + 0.3 + 0.3 = 1.0
乱数 roll = 0.65 の場合:
0.65 - 0.4(attack) = 0.25 → まだ正 → 次へ
0.25 - 0.3(spell) = -0.05 → 負になった → spell を選択!
数直線で表すと:
|--- attack (40%) ---|--- spell (30%) ---|--- special (30%) ---|
0 0.4 0.7 1.0
↑ roll=0.65
このアルゴリズムの計算量は O(n)(n = 行動の種類数)です。行動数が少ないゲームでは十分高速ですし、重みを変えるだけで敵ごとの「性格」を表現できます。
// スライム: 攻撃しかしない単純な敵
slime: { actions: [{ type: 'attack', weight: 1.0 }] }
// ゴブリン: たまに防御する慎重な敵
goblin: { actions: [{ type: 'attack', weight: 0.7 }, { type: 'defend', weight: 0.3 }] }
// スケルトン: 魔法も使う知的な敵
skeleton: { actions: [{ type: 'attack', weight: 0.6 }, { type: 'spell', spell: 'fire', weight: 0.4 }] }
第5章:タイルマップ描画と視錐台カリング
2Dゲームの描画で最も重要な最適化技法が**視錐台カリング(Frustum Culling)**です。3Dゲームでは視錐台(カメラの視野)の外にあるオブジェクトを描画しないという最適化が常識ですが、2Dタイルマップでもまったく同じ考え方が使えます。
最適化なしの場合
30×25 のフィールドマップをまるごと描画すると、毎フレーム 750タイル を drawImage することになります。60FPSでは毎秒 45,000回 の描画呼び出しです。
視錐台カリングあり
// tilemap.js
renderLayer(ctx, layerData, camera) {
// ★ カメラの位置から、画面に映るタイル範囲だけを計算
const startCol = Math.max(0, Math.floor(camera.x / TILE_SIZE));
const startRow = Math.max(0, Math.floor(camera.y / TILE_SIZE));
const endCol = Math.min(this.data.width,
startCol + Math.ceil(CANVAS_WIDTH / TILE_SIZE) + 2);
const endRow = Math.min(this.data.height,
startRow + Math.ceil(CANVAS_HEIGHT / TILE_SIZE) + 2);
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
const tileIndex = layerData[row * this.data.width + col];
if (tileIndex === 0) continue; // 透明タイルはスキップ
// ★ タイルセット上の座標を逆算
const srcX = (tileIndex % this.tilesPerRow) * TILE_SIZE;
const srcY = Math.floor(tileIndex / this.tilesPerRow) * TILE_SIZE;
// ★ 画面上の座標(カメラのオフセットを引く)
const destX = Math.floor(col * TILE_SIZE - camera.x);
const destY = Math.floor(row * TILE_SIZE - camera.y);
ctx.drawImage(this.tileset,
srcX, srcY, TILE_SIZE, TILE_SIZE,
destX, destY, TILE_SIZE, TILE_SIZE);
}
}
}
画面サイズは 512×448px、タイルサイズは 32px なので、描画範囲は最大 (16+2) × (14+2) = 288タイル です。マップ全体の750タイルに対して 約62%の描画を削減 できます。+2 のバッファは、カメラがタイルの途中にあるとき端が見切れないようにするためです。
2次元配列を1次元で扱う理由
タイルデータは2次元のグリッドですが、1次元配列として格納しています。
// アクセス: layerData[row * width + col]
// これは layerData[y][x] と等価だが、メモリ配置が連続する
この「行優先(Row-major)」のレイアウトは、C言語の2次元配列と同じメモリ配置です。JavaScriptの配列は厳密にはオブジェクトなので恩恵は限定的ですが、コードの一貫性と可読性の面で優れています。
第6章:画家のアルゴリズム ― 2Dゲームの描画順序
「奥にあるものを先に描き、手前にあるものを後から描く」――この単純な原則が**画家のアルゴリズム(Painter's Algorithm)**です。
本プロジェクトの描画順序は以下の通りです。
// scene-explore.js - render()
render(ctx) {
// 1. 地面レイヤー(最背面)
this.tilemap.renderLayer(ctx, this.tilemap.data.layers.ground, this.camera);
// 2. 壁・障害物レイヤー
this.tilemap.renderLayer(ctx, this.tilemap.data.layers.above, this.camera);
// 3. 宝箱・ボスマーカー
this.renderChests(ctx);
this.renderBossMarker(ctx);
// 4. エンティティ(プレイヤー + NPC)を Y座標でソート
const entities = [...this.npcs, this.player].filter(e => e && e.visible);
entities.sort((a, b) => a.pixelY - b.pixelY); // ★ Y値が小さい(奥)が先
entities.forEach(e => e.render(ctx, this.camera));
// 5. オーバーレイレイヤー(屋根、木の上部など)
if (this.tilemap.data.layers.overlay) {
this.tilemap.renderLayer(ctx, this.tilemap.data.layers.overlay, this.camera);
}
}
注目すべきはステップ4のY座標ソートです。画面上で「下にいるキャラクター=手前にいる」という2Dゲーム特有の奥行き表現を、Y座標の昇順ソートだけで実現しています。
Y=100 のNPC → 先に描画(奥)
Y=150 のプレイヤー → 後に描画(手前、NPCの上に重なる)
この手法は簡潔ですが、多数のエンティティがある場合はソートのコスト O(n log n) が問題になります。本プロジェクトではエンティティ数が少ないため十分高速です。
第7章:タイルベースの移動と線形補間
古典的RPGの移動は「1マスずつカクカク動く」イメージがありますが、実際には**線形補間(Linear Interpolation, Lerp)**でスムーズに描画されています。
// player.js
update(tilemap, npcs) {
if (this.moving) {
this.moveProgress += PLAYER_SPEED; // 毎フレーム4pxずつ進む
// ★ 現在タイルと目標タイルの間を線形補間
const dx = this.targetTileX - this.tileX; // -1, 0, or 1
const dy = this.targetTileY - this.tileY;
this.pixelX = this.tileX * TILE_SIZE + dx * this.moveProgress;
this.pixelY = this.tileY * TILE_SIZE + dy * this.moveProgress;
// 歩行アニメーション(4フレームごとに切替)
this.animTimer++;
if (this.animTimer >= 4) {
this.animFrame = (this.animFrame + 1) % 2;
this.animTimer = 0;
}
// 1タイル分(32px)移動完了
if (this.moveProgress >= TILE_SIZE) {
this.moving = false;
this.moveProgress = 0;
this.tileX = this.targetTileX;
this.tileY = this.targetTileY;
return 'arrived';
}
}
}
何が起きているのか
タイルサイズが32px、移動速度が4px/フレームなので、1マスの移動に 8フレーム(約133ms) かかります。
フレーム0: pixelX = tileX * 32 + 0 (開始)
フレーム1: pixelX = tileX * 32 + 4
フレーム2: pixelX = tileX * 32 + 8
...
フレーム7: pixelX = tileX * 32 + 28
フレーム8: pixelX = tileX * 32 + 32 (到着 → 次のタイルへ)
論理的な位置は「タイル座標」で管理し、描画位置は「ピクセル座標」で補間する。このデュアル座標系は、グリッドベースのゲームの定石です。衝突判定はタイル座標で行い、見た目はピクセル座標で滑らかに見せます。
第8章:入力システムの設計 ― 「押した瞬間」と「押し続けている」を分離する
ゲームの入力処理には、Webアプリとは異なる特殊な要件があります。
// input.js
const Input = {
held: {}, // 押し続けているキー
justPressed: {}, // このフレームで新たに押されたキー
init() {
window.addEventListener('keydown', (e) => {
if (!this.held[e.code]) {
this.justPressed[e.code] = true; // ★ 初回のみ true
}
this.held[e.code] = true;
});
window.addEventListener('keyup', (e) => {
this.held[e.code] = false;
});
// ★ タブ切替時に全キーをリセット(押しっぱなし防止)
window.addEventListener('blur', () => {
this.held = {};
this.justPressed = {};
});
},
// 毎フレーム末尾で呼ばれる
endFrame() {
this.justPressed = {}; // ★ 「押した瞬間」は1フレームだけ有効
}
};
なぜ2種類の入力状態が必要なのか
held(押し続けている): キャラクターの移動に使用。押し続けている間ずっと歩き続ける。
justPressed(押した瞬間): メニューの決定、テキスト送り。もしこれが無いと、Zキーを1フレーム押しただけでメニューが一瞬で何段も進んでしまいます。
endFrame() で毎フレーム justPressed をクリアすることで、「押した瞬間」が正確に1フレームだけ有効になります。このパターンはUnityの Input.GetKeyDown() や Godot の is_action_just_pressed() と同じ概念です。
blur イベントでの全キーリセットも重要です。これがないと、ゲーム中にAlt+Tabした際に keyup が発火せず、キャラクターが永遠に歩き続けるバグが発生します。
第9章:ランダムエンカウントの確率設計
RPGのランダムエンカウントは、単純に「毎歩○%の確率でバトル」ではありません。本プロジェクトでは歩数に比例して確率が上昇する方式を採用しています。
// scene-explore.js
checkEncounter() {
if (!mapData || mapData.encounterRate === 0) return;
this.stepsSinceEncounter++; // 最後の戦闘からの歩数
const threshold = mapData.encounterRate; // マップごとの基準値
const chance = this.stepsSinceEncounter / (threshold * 2); // 歩くほど確率上昇
if (Math.random() < chance) {
this.stepsSinceEncounter = 0; // エンカウントしたらリセット
// ランダムに敵グループを選択して戦闘開始
const group = mapData.encounterTable[
Math.floor(Math.random() * mapData.encounterTable.length)
];
this.startBattle(group, false);
}
}
確率の推移
フィールドマップ(encounterRate: 18)の場合:
| 歩数 | 確率 | 累積エンカウント率 |
|---|---|---|
| 1歩目 | 2.8% | 低い |
| 10歩目 | 27.8% | 中程度 |
| 18歩目 | 50.0% | ここが基準 |
| 30歩目 | 83.3% | ほぼ確実 |
| 36歩目 | 100% | 必ずエンカウント |
この方式のメリットは、「10歩連続でエンカウントなし」のような不運が起きにくいことです。歩けば歩くほど確率が上がるため、プレイヤーの体感としてムラが少なくなります。マップごとに encounterRate を変えるだけで、安全な村(0)、適度な緊張感のフィールド(18)、高エンカウントのダンジョン(14)を表現できます。
第10章:データ駆動設計 ― ロジックとデータの分離
ソフトウェア工学において**関心の分離(Separation of Concerns)**は最も重要な設計原則のひとつです。本プロジェクトでは、ゲームの振る舞いを決める数値データをすべて独立したファイルに分離しています。
data/
├── enemies.js # 敵のステータス・行動パターン・ドロップテーブル
├── items.js # アイテム・武器・防具の定義
├── spells.js # 魔法の効果・消費MP
├── levels.js # レベルテーブル・ステータス成長率・魔法習得表
└── maps/ # マップデータ(タイル配置・衝突・イベント)
// spells.js - 魔法データベース
const SpellDB = {
heal: { name: 'ヒール', type: 'heal', power: 30, mpCost: 4, desc: 'HPを30回復' },
heal2: { name: 'ヒール II', type: 'heal', power: 80, mpCost: 10, desc: 'HPを80回復' },
fire: { name: 'ファイア', type: 'damage', power: 25, mpCost: 5, element: 'fire' },
ice: { name: 'アイス', type: 'damage', power: 28, mpCost: 6, element: 'ice' },
sleep: { name: 'スリープ', type: 'status', chance: 0.5, mpCost: 3, status: 'sleep' },
};
// items.js - 装備にはステータス補正が付く(マイナス補正もある)
plateArmor: {
name: 'プレートアーマー',
type: 'armor', slot: 'armor',
stats: { defense: 16, speed: -2 }, // ★ 防御力+16だが素早さ-2
price: 350
},
ステータス計算の一元管理
装備による補正は、参照のたびに動的に計算されます。
// party.js
getStat: function(member, statName) {
var total = member.baseStats[statName] || 0;
// 全装備スロットを走査して、該当ステータスの補正を加算
for (var slot in member.equipment) {
var itemId = member.equipment[slot];
if (itemId && ItemDB[itemId] && ItemDB[itemId].stats) {
total += (ItemDB[itemId].stats[statName] || 0);
}
}
return total;
}
「計算済みのステータスをキャッシュして持つ」のではなく、毎回基礎値+装備から計算する設計です。これにより装備を変更した瞬間にすべてのステータス参照に反映され、「キャッシュの更新忘れ」によるバグが構造的に発生しません。**Single Source of Truth(信頼できる唯一の情報源)**の原則です。
第11章:BGMクロスフェード ― モジュールパターンとブラウザの制約
IIFE によるモジュールパターン
BGMマネージャーは即時実行関数式(IIFE)で実装されており、GoFデザインパターンのSingletonパターンをJavaScriptらしく実現しています。
// bgm.js
var BGM = (function () {
// ★ これらの変数はクロージャにより外部からアクセス不可能(プライベート)
var _audioA = null;
var _audioB = null;
var _active = null;
var _locked = false;
function play(key) {
if (_locked) return; // ボス戦中はBGM変更を拒否
// ★ A/Bの2つのAudio要素を交互に使用してクロスフェード
var fadeIn, fadeOut;
if (_active === 'A') {
fadeIn = _audioB; fadeOut = _audioA; _active = 'B';
} else {
fadeIn = _audioA; fadeOut = _audioB; _active = 'A';
}
fadeIn.src = _tracks[key];
fadeIn.volume = 0;
fadeIn.currentTime = 0;
// ★ 30ステップ(約500ms)でクロスフェード
var step = 0, totalSteps = 30;
_fadeTimer = setInterval(function () {
step++;
var t = step / totalSteps; // 0.0 → 1.0 に線形補間
fadeIn.volume = Math.min(1, t); // 新曲: 0 → 1
if (fadeOut && !fadeOut.paused) {
fadeOut.volume = Math.max(0, 1 - t); // 旧曲: 1 → 0
}
if (step >= totalSteps) clearInterval(_fadeTimer);
}, 16); // ≒60FPS
}
// ★ 公開APIのみを返す(情報隠蔽)
return { init: init, play: play, stop: stop, lock: lock, unlock: unlock };
})();
ブラウザの自動再生ポリシーへの対処
2018年以降、主要ブラウザはユーザー操作なしでの音声自動再生を禁止しています。本プロジェクトでは、初回のユーザー操作(クリック / キー入力 / タッチ)を検知して音声を解放する仕組みを実装しています。
function onInteraction() {
if (!_unlocked) {
_unlocked = true;
// キューされていた曲を再生開始
if (_currentTrack) {
var audio = _active === 'A' ? _audioA : _audioB;
if (audio && audio.paused) audio.play().catch(function () {});
}
}
// 一度解除したらリスナーを削除
document.removeEventListener('click', onInteraction);
document.removeEventListener('keydown', onInteraction);
document.removeEventListener('touchstart', onInteraction);
}
.catch(function () {}) で再生失敗を握りつぶしているのは、再生に失敗してもゲーム自体は続行させるためです。BGMは付加的な要素であり、ゲームプレイの根幹ではないため、エラーで止まるべきではないという判断です。
第12章:レスポンシブデザインとピクセルパーフェクト描画
ドット絵のスケーリング問題
ブラウザはデフォルトで画像をバイリニアフィルタリング(滑らかに補間)してスケーリングします。写真には適切ですが、ドット絵では致命的にぼやけます。
/* style.css */
canvas {
image-rendering: pixelated; /* Chrome, Edge */
image-rendering: crisp-edges; /* Firefox */
}
// game.js
this.ctx.imageSmoothingEnabled = false; // Canvas内部の描画でも補間を無効化
CSSとCanvas APIの両方で指定するのがポイントです。CSSは表示スケーリング、Canvas APIは drawImage 時の補間をそれぞれ制御します。
3段階のレスポンシブ対応
/* デスクトップ: 内部解像度512x448を2倍に拡大表示 */
@media (min-width: 1025px) {
canvas { width: 1024px; height: 896px; }
}
/* タブレット: 等倍 */
@media (min-width: 513px) and (max-width: 1024px) {
canvas { width: 512px; height: 448px; }
}
/* スマホ: 画面幅にフィット */
@media (max-width: 512px) {
canvas { width: 100vw; }
}
内部解像度は常に512×448pxのままで、CSSで表示サイズだけを変えています。これはレトロゲーム機のアップスケーリングと同じアプローチです。ゲームロジックは解像度に依存しないため、どの画面サイズでも同じ動作が保証されます。
第13章:条件分岐する会話システム
NPCの会話は、ゲームの進行状況に応じて内容が変化します。
// dialogue-data.js
elder_intro: {
pages: [
{
// ★ 初回会話: 剣をもらう
condition: function(flags) { return !flags.elder_talked; },
text: [
{ speaker: '長老', msg: 'よく来た、若き勇者よ。' },
{ speaker: '', msg: 'ショートソードを手に入れた!' },
],
onComplete: function(flags, inventory) {
flags.elder_talked = true;
inventory.add('shortSword', 1);
}
},
{
// ★ ボス撃破前: ヒントを話す
condition: function(flags) { return flags.elder_talked && !flags.boss_defeated; },
text: [{ speaker: '長老', msg: '北の洞窟へ急ぐのじゃ。' }]
},
{
// ★ ボス撃破後: エンディングへ
condition: function(flags) { return flags.boss_defeated; },
text: [{ speaker: '長老', msg: 'おお、やってくれたか!' }],
onComplete: function(flags) {
window.game.transitionTo('ending');
}
}
]
}
// scene-dialogue.js - 最初にマッチした条件のページを表示
let page = null;
for (const p of dialogue.pages) {
if (!p.condition || p.condition(flags)) {
page = p;
break;
}
}
会話の分岐、アイテム付与、フラグ更新、シーン遷移がすべてデータファイルに宣言的に記述されています。新しいNPCやイベントを追加するとき、ロジックのコードを変更する必要はありません。データを追加するだけです。これは宣言的プログラミングの好例です。
まとめ:ゲームプログラミングで学べる計算機科学
本プロジェクトを通じて、以下の計算機科学の概念を実践的に学ぶことができました。
| 概念 | ゲームでの応用 |
|---|---|
| 有限状態機械(FSM) | シーン管理、バトルフロー、画面遷移 |
| 固定タイムステップ | フレームレート非依存のゲームループ |
| 線形補間(Lerp) | タイル間のスムーズな移動アニメーション |
| 視錐台カリング | 画面外タイルの描画スキップ |
| 画家のアルゴリズム | Y座標ソートによる描画順序制御 |
| 重み付きランダム選択 | 敵AIの行動決定 |
| 確率分布 | ダメージ計算の分散、クリティカル率 |
| Single Source of Truth | 装備補正の動的計算 |
| データ駆動設計 | ロジックとデータの完全分離 |
| Singletonパターン | BGMマネージャー、入力システム |
| Stateパターン | シーン管理の委譲構造 |
| モジュールパターン(IIFE) | プライベート変数の隠蔽 |
ゲームエンジンは便利ですが、その裏で何が動いているかを知ることは、エンジニアとしての基礎体力になります。「なぜ requestAnimationFrame なのか」「なぜFSMなのか」「なぜカリングするのか」――これらの「なぜ」に自分で答えられるようになったことが、このプロジェクトの最大の収穫でした。
ソースコードはすべて公開しています。ぜひ遊んでみて、コードを読んでみてください。
デモ(ブラウザで遊べます): https://nobu-suzuki345.github.io/shadow-cave/
ソースコード: https://github.com/nobu-suzuki345/shadow-cave
操作方法
| 操作 | キーボード | スマホ |
|---|---|---|
| 移動 | 矢印キー / WASD | 十字キー |
| 決定 | Z / Enter / Space | Aボタン |
| キャンセル | X / Escape | Bボタン |
| メニュー | C / Escape | Menuボタン |