Canvas 2Dで作ったレトロなドッジゲーム。90年代の操作感を再現した
旧サイトのデモを開く
実際にゲームを遊べる旧サイトのデモページです。新しいタブで開きます。
デモを開く
90年代のゲーム機で遊んだ記憶が、ふとよみがえった。スーパーファミコンの頃、シンプルなドッジゲームに何時間も没頭していた。敵を避けながらコインを集める。それだけのルールなのに、なぜか夢中になった。
ドッジゲームというジャンルは、1970年代のアーケードゲームから続く長い歴史がある。スペースインベーダーやギャラクシアンも、広義ではドッジゲームの一種だ。敵の弾を避けながら戦う。シンプルなルールだが、緊張感と爽快感がある。
90年代のゲーム機には技術的な制約があった。スーパーファミコンは256×224ピクセルの解像度。16色同時表示。スプライトは最大128個。CPUは3.58MHz。現代のスマホと比べると、性能は数万倍も低い。それでも、当時のゲームは面白かった。制約があるからこそ、工夫が生まれる。
今、Canvas 2Dで同じようなゲームを作ってみた。256×224ピクセルという小さな画面。16ピクセル単位のタイルパターン。ピクセルアート風のキャラクター。当時の雰囲気を再現しようと試みた。ただし、現代の技術を活かして、スマホでも遊べるようにした。
ゲームの基本ルール
プレイヤーは緑色の四角。敵を避けながら、画面に散らばるコインを集める。コインを取ると100点。500点ごとにレベルが上がり、敵が増える。
敵は3種類。赤いChaserはプレイヤーを追いかける。紫のDasherは時々ダッシュして襲ってくる。オレンジのPatrollerは決まったルートを巡回する。
パワーアップアイテムもある。シールドは1回だけ敵の攻撃を防げる。フリーズは2.5秒間、敵の動きを遅くする。
入力処理の実装
キーボードとタッチ操作の両方に対応した。最初はキーボードだけを想定していたが、スマホでも遊べるようにタッチ操作を追加した。
入力処理はInputHandlerクラスで管理している。キーボードの矢印キーやWASD、タッチ操作のジョイスティック、両方の入力を統合して方向ベクトルを返す。
90年代のゲーム機では、入力はアナログスティックか十字キーだった。現代のブラウザでは、キーボード、マウス、タッチ、ゲームパッドなど、多様な入力デバイスに対応する必要がある。それぞれのデバイス特性を考慮して、統一的なインターフェースを作った。
// midori311/index.html (238-328行目)
class InputHandler {
constructor() {
this.keys = {};
this.joy = { x: 0, y: 0 };
// キーボードイベントの登録
window.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
this.keys[e.code] = true;
});
// タッチ操作のジョイスティック実装
// ...
}
getVector() {
let x = 0;
let y = 0;
if (this.keys['ArrowUp'] || this.keys['KeyW']) y -= 1;
if (this.keys['ArrowDown'] || this.keys['KeyS']) y += 1;
if (this.keys['ArrowLeft'] || this.keys['KeyA']) x -= 1;
if (this.keys['ArrowRight'] || this.keys['KeyD']) x += 1;
x += this.joy.x;
y += this.joy.y;
const len = Math.hypot(x, y);
if (len > 1) {
x /= len;
y /= len;
}
return { x, y };
}
}
タッチ操作のジョイスティックは、画面左下に配置した。120×120ピクセルの円形エリアで、タッチ位置に応じてノブが動く。ノブが円の中心から離れるほど、移動速度が上がる仕組みだ。
実装で苦労したのは、マルチタッチの処理だった。最初はtouchstartとtouchmoveを使っていたが、スクロールと衝突してしまった。Pointer Events APIを使うことで解決した。setPointerCaptureでポインターをキャプチャし、画面外に指が動いても追跡できるようにした。
ジョイスティックの感度調整も試行錯誤した。最初は線形に反応していたが、操作感が悪かった。中心付近は感度を下げ、外側に行くほど感度を上げる非線形マッピングに変更した。これで、細かい操作と大きな操作の両方に対応できるようになった。
音響効果の追加
レトロゲームの雰囲気を出すなら、音は欠かせない。短い電子音だけでも、体験の厚みが変わる。
Web Audio APIを使って、コイン取得音、パワーアップ音、レベルアップ音、ゲームオーバー音を作った。オシレーターで単純な波形を鳴らすだけだが、それがレトロっぽい。複雑な音源を使わない方が、むしろ雰囲気が出る。
ブラウザによっては音が鳴らないことがあるので、try-catchで安全に処理した。音が鳴らなくてもゲームは動く。これは大事な設計ポイントだ。
敵AIの実装
3種類の敵、それぞれに異なる行動パターンを実装した。ゲームデザインの観点から、プレイヤーに異なる種類の緊張感を与えるように設計した。
Chaserはプレイヤーを追いかける。プレイヤーとの距離を計算し、その方向に加速する。速度には上限があり、レベルが上がるほど上限も上がる。
実装では、加速度ベースの移動を採用した。速度を直接変更するのではなく、加速度を加算してから速度を制限する。これで、慣性のある動きになり、より自然に見える。90年代のゲームでも、この手法はよく使われていた。
// midori311/index.html (381-402行目)
class Chaser extends Enemy {
constructor() {
super(randEdgeX(), randEdgeY(), 14, 46, 'chaser');
}
update(dt, player) {
const realDt = this.applyFreeze(dt);
const dx = player.cx - this.cx;
const dy = player.cy - this.cy;
const dist = Math.hypot(dx, dy) || 1;
this.vx += (dx / dist) * 40 * realDt;
this.vy += (dy / dist) * 40 * realDt;
const spd = Math.hypot(this.vx, this.vy) || 1;
const limit = this.speed + level * 2;
if (spd > limit) {
this.vx = (this.vx / spd) * limit;
this.vy = (this.vy / spd) * limit;
}
this.x += this.vx * realDt;
this.y += this.vy * realDt;
bounceWithin(this);
}
}
Dasherは一定時間ごとにダッシュする。クールダウンタイマーが0になると、プレイヤーの方向に高速で突進する。ダッシュ中は0.4秒間、通常の3倍の速度で動く。
ダッシュのタイミングは、プレイヤーが油断した時に来るように調整した。クールダウンは0.6〜1.2秒のランダム。完全に予測できないが、ある程度のパターンは読める。これで、緊張感と戦略性のバランスを取った。
Patrollerはランダムな目標地点を目指して移動する。目標に到達すると、新しい目標を設定する。プレイヤーを直接追わないため、予測しにくい動きになる。
Patrollerの存在意義は、画面の動きを複雑にすることだ。ChaserとDasherだけだと、プレイヤーは画面の端に逃げるだけで済んでしまう。Patrollerが画面内を動き回ることで、安全地帯がなくなる。これで、常に緊張感が保たれる。
パーティクルエフェクト
コインを取った時、敵に当たった時、パワーアップを取った時。それぞれにパーティクルエフェクトを追加した。
90年代のゲーム機では、パーティクルエフェクトは限られていた。スプライトの数に制約があるため、大量のパーティクルは使えない。現代のCanvas 2Dでは、理論上は無制限にパーティクルを表示できる。ただし、パフォーマンスを考慮して、適切な数に制限する必要がある。
パーティクルは配列で管理している。各パーティクルには位置、速度、寿命、色、サイズの情報がある。寿命が0になると配列から削除される。
最初は、パーティクルをオブジェクトとして管理していた。しかし、メモリの確保と解放が頻繁に発生し、ガベージコレクション(GC)が頻発した。GCが発生すると、フレームレートが一時的に下がる。これを避けるため、オブジェクトプールを実装した。パーティクルを再利用することで、メモリの確保と解放を最小限に抑えた。
// midori311/index.html (555-567行目)
function spawnSpark(x, y, color, count = 10) {
for (let i = 0; i < count; i++) {
particles.push({
x,
y,
vx: (Math.random() - 0.5) * 80,
vy: (Math.random() - 0.5) * 80,
life: 0.3 + Math.random() * 0.2,
color,
size: Math.random() < 0.5 ? 1 : 2
});
}
}
プレイヤーが動いている時は、軌跡を残すようにした。移動速度が一定以上の場合、プレイヤーの位置に小さなパーティクルを生成する。これで動きに躍動感が生まれる。
画面シェイクと危険度表示
敵に当たった時、画面が揺れる。シェイク効果を実装した。
shakeTimeとshakeStrengthという変数で管理している。シェイクが発生すると、描画時にランダムなオフセットを加える。時間が経つと強度が減衰し、最終的に0になる。
// midori311/index.html (696-702行目)
function draw() {
ctx.save();
if (shakeStrength > 0) {
const sx = (Math.random() - 0.5) * shakeStrength;
const sy = (Math.random() - 0.5) * shakeStrength;
ctx.translate(sx, sy);
}
// 描画処理...
}
危険度表示も追加した。プレイヤーから60ピクセル以内に敵がいる場合、画面全体に赤いオーバーレイを表示する。距離が近いほど濃くなる。これで危険を直感的に伝えられる。
背景のアニメーション
背景は2層構造になっている。下層は暗い青、上層は少し明るい青。その上にタイルパターンを重ねている。
タイルは16×16ピクセル。時間とともにスクロールする。チェッカーパターンのように、交互に色を変えている。
星も動いている。26個の星が上から下に流れる。フリーズアイテムを使うと、星の動きが遅くなる。時間が止まったような感覚を演出する。
// midori311/index.html (724-735行目)
function drawTilePattern() {
const offset = Math.floor((time * 18) % TILE);
for (let y = -TILE; y < H + TILE; y += TILE) {
for (let x = -TILE; x < W + TILE; x += TILE) {
const isAlt = ((x + y) / TILE) % 2 === 0;
ctx.fillStyle = isAlt ? palette.tile1 : palette.tile2;
ctx.fillRect(x + offset, y + offset, TILE, TILE);
ctx.strokeStyle = palette.grid;
ctx.strokeRect(x + offset, y + offset, TILE, TILE);
}
}
}
ゲームループとパフォーマンス
ゲームループはrequestAnimationFrameを使っている。フレームレートは60FPSを目指しているが、処理が重い場合は自動的に調整される。
requestAnimationFrameは、ブラウザが最適なタイミングでコールバックを呼び出す。通常は60FPSだが、タブが非アクティブになると自動的に停止する。これで、バッテリーの消費を抑えられる。
dt(デルタタイム)は前フレームからの経過時間。最大0.033秒(約30FPS)に制限している。これで処理が重くなっても、ゲームの速度が一定に保たれる。
固定タイムステップと可変タイムステップのどちらを使うか、悩んだ。固定タイムステップは物理演算が安定するが、フレームレートが低い時にカクつく。可変タイムステップは滑らかだが、物理演算が不安定になる可能性がある。今回は、可変タイムステップに上限を設ける方式を採用した。これで、滑らかさと安定性のバランスを取った。
// midori311/index.html (688-694行目)
function gameLoop(timestamp) {
const dt = Math.min(0.033, (timestamp - lastTime) / 1000);
lastTime = timestamp;
update(dt);
draw();
if (gameState === 'PLAYING') requestAnimationFrame(gameLoop);
}
パフォーマンステストをした。PCでは60FPSを維持できた。スマホでも30FPS以上は出ている。パーティクルの数は最大100個程度に制限している。これ以上増やすと、処理が重くなる。
パフォーマンス最適化で試したことをいくつか挙げる。
まず、描画の最適化。Canvas 2DのfillRectは、毎回呼び出すとオーバーヘッドが大きい。同じ色で複数の矩形を描く場合は、一度だけfillStyleを設定してから、連続してfillRectを呼ぶ。これで、描画コールの数を減らせる。
次に、衝突判定の最適化。全ての敵とプレイヤーの距離を計算するのは重い。画面外の敵は衝突判定から除外した。また、距離が一定以上離れている敵は、詳細な衝突判定をスキップした。これで、計算量を大幅に削減できた。
最後に、メモリ管理。パーティクルや敵の配列を、頻繁にpushやspliceすると、メモリの再配置が発生する。可能な限り、事前に配列のサイズを確保しておく。これで、メモリの再配置を減らせる。
ハイスコアと統計の表示
記録が残ると、ゲームは長く遊ばれる。ハイスコアをlocalStorageに保存し、スタート画面でいつでも確認できるようにした。
ついでに統計情報も集めた。プレイ時間、コイン取得数、シールド・フリーズの取得数、危険な距離を避けた回数。自分のプレイ癖が見えてくる。数値は控えめに、でも「遊びの履歴」として残しておく。
ポーズ機能
スマホで遊んでいると、突然の通知で画面が切り替わることがある。そんな時にポーズがないと、不意にゲームオーバーになる。そこでPキーと画面内のPAUSEボタンで一時停止できるようにした。
ポーズ中は描画だけを続け、更新処理を止める。これで動きは止まるが、画面は生きている。昔のゲーム機でもよく使われていた手法だ。
難易度選択
EASY / NORMAL / HARD の3段階を用意した。敵の速度と出現頻度を変えるだけだが、それだけで別のゲームに見える。
難易度はlocalStorageに保存している。毎回設定し直す必要はない。これも小さなストレスを減らす工夫のひとつ。
エフェクト強化
連続でコインを取ったときのコンボ表示、レベルアップ時のフラッシュ、スコアがふわっと浮かぶ演出。大きな追加ではないが、操作の手応えが増した。
こういう小さなフィードバックが、ゲームの気持ちよさを支えている。操作に対して「返事がある」ことが大事だ。
色の選定
パレットは手動で選んだ。90年代のゲームを参考にした。
90年代のゲーム機は、同時表示できる色数に制約があった。スーパーファミコンは32,768色中、同時に256色まで表示できる。ただし、実際のゲームでは、パレットを複数用意して、シーンごとに切り替えていた。今回は、1つのパレットで統一感を出した。
背景は暗い青系。プレイヤーは明るい緑。敵は赤、紫、オレンジ。コインは黄色。シールドは水色。フリーズは淡い青。
コントラストを意識した。暗い背景に明るいキャラクター。これで視認性が上がる。
色の選定で参考にしたのは、アクセシビリティのガイドラインだ。WCAG(Web Content Accessibility Guidelines)では、テキストと背景のコントラスト比を4.5:1以上にすることを推奨している。ゲームでは、この基準を少し緩めたが、基本的な考え方は同じ。視認性を最優先にした。
敵の色分けも工夫した。Chaserは赤、Dasherは紫、Patrollerはオレンジ。色だけで敵の種類を識別できるようにした。色覚多様性を考慮して、形も少し変えている。Chaserは少し大きく、Dasherは小さく、Patrollerは中間サイズ。これで、色だけでなく、形でも識別できる。
試行錯誤の過程
最初は敵が1種類だけだった。それだと単調すぎる。3種類に増やした。
敵の速度調整に苦労した。速すぎると避けられない。遅すぎると退屈になる。何度も調整して、今のバランスに落ち着いた。
具体的な数値で言うと、Chaserの基本速度は46ピクセル/秒。レベルが上がるごとに2ピクセル/秒ずつ増える。レベル10では66ピクセル/秒になる。プレイヤーの速度は88ピクセル/秒なので、常に逃げ切れるわけではない。これで、緊張感が保たれる。
Dasherのダッシュ速度は140ピクセル/秒。これは、プレイヤーの速度の約1.6倍。一瞬の判断ミスで当たってしまう。ただし、ダッシュの前には予兆がある。クールダウン中は速度が落ちるので、その間に距離を取れる。これで、完全に運任せではなく、技術で対応できる余地を残した。
Patrollerの速度は60ピクセル/秒。プレイヤーより遅いが、予測不能な動きで脅威になる。目標地点までの距離が6ピクセル以下になると、新しい目標を設定する。この閾値を調整するのに時間がかかった。小さすぎると頻繁に方向転換して不自然になる。大きすぎると、目標に到達する前に新しい目標を設定してしまう。
パワーアップアイテムの出現頻度も調整した。最初は3秒ごとに出現していたが、多すぎた。6〜10秒のランダムに変更した。
難易度カーブの設計も試行錯誤した。最初は、レベルが上がるごとに敵の数だけを増やしていた。しかし、これだと後半が単調になる。敵の種類を増やすタイミングを調整し、レベル3の倍数でDasher、レベル2の倍数でPatrollerを追加するようにした。これで、難易度が段階的に上がるようになった。
画面サイズの調整も苦労した。256×224ピクセルは、現代の画面では小さすぎる。拡大表示すると、ピクセルがぼやける。image-rendering: pixelatedを設定することで、ピクセルアートの美しさを保った。ただし、ブラウザによって挙動が異なる。ChromeとFirefoxでは問題ないが、Safariでは追加の設定が必要だった。
まとめ
Canvas 2Dでレトロなドッジゲームを作った。90年代の雰囲気を再現しようと試みた。
3種類の敵、パワーアップアイテム、パーティクルエフェクト。シンプルなルールだが、遊び応えはある。
スマホでも遊べるように、タッチ操作に対応した。ジョイスティックの実装は、思ったより手間がかかった。
ゲーム開発は楽しい。小さな画面でも、工夫次第で面白い体験を作れる。
90年代のゲーム機の技術的制約を、現代の技術で再現するのは興味深い体験だった。制約があるからこそ、創造性が発揮される。現代の技術は強力だが、使いすぎると逆に面白みがなくなる。適度な制約が、良いゲームを作る鍵になる。
実際に遊んでみてほしい。旧サイトのデモも公開している。スマホでもPCでも、同じ体験ができるように最適化した。
さらに深く学ぶには