ブラウザ版メイズマンチャーの実装を解説
固定マップの作成からCanvas描画、ゴーストの追跡、衝突判定、レベル進行、BGM・効果音まで、ゲームページの仕組みを順番に紹介します。
メイズマンチャーは、迷路のドットを集めながら、追跡してくるゴーストをかわすアクションゲームです。マインスイーパーがマス同士の関係を調べるゲームだとすれば、メイズマンチャーはマップ上の通行可能な場所とキャラクターの位置関係をリアルタイムに扱うゲームです。
ゲーム本体は、PHPで共通のページ部分を読み込み、HTMLで操作パネルを用意し、JavaScriptとCanvasでゲームを動かしています。盤面は19×19マスの固定マップですが、毎回ドットを集め直し、ゴーストの行動やレベル進行をJavaScriptで管理します。
迷路を文字列の配列で表現する
最初に必要なのは、どこが壁で、どこを歩けるかを表すマップです。実装では、1行を1本の文字列として持ち、それを19行並べています。#が壁、.が通常のドットです。文字で書けるため、迷路の形をソースコードから確認しやすいのが特徴です。
const MAP = [
'###################',
'#........#........#',
'#..###.......###..#',
'#.....#.....#.....#',
'#.................#',
'###################'
];
const COLS = 19;
const ROWS = 19;
const TILE = 30;Canvas上では、マスの座標にTILEを掛けます。たとえば、列2・行3のマスは、左上がx = 2 * 30、y = 3 * 30になります。このルールをプレイヤー、ゴースト、ドットの描画で共通して使うことで、すべての要素が同じ迷路上に並びます。
壁、ドット、パワーアイテムを別々に管理する
マップ文字列は通行できるかどうかの基準として残し、ゲーム中に変化するドットは別のboard配列へコピーしています。壁はnull、通常のドットは1、パワーアイテムは2、何もない通路は0です。
function newBoard() {
board = MAP.map(row => row.split('').map(cell =>
cell === '#' ? null : cell === '.' ? 1 : 0
));
pelletStarts.forEach(([x, y]) => {
if (isOpen(x, y)) board[y][x] = 2;
});
board[9][9] = 0;
}四隅のpelletStartsには大きなパワーアイテムを置き、中央のゴーストが出現する場所にはドットを置きません。マップそのものと、集められたかどうかを示す盤面を分けているため、レベル開始時にドットだけを作り直せます。
入力した方向を次の移動に予約する
プレイヤーは矢印キー、WASD、画面下のタッチボタンで操作できます。キーを押した瞬間に座標を変えるのではなく、入力した方向をplayer.nextに保存し、次の更新時に進めるなら採用します。この方式にすると、曲がり角の少し手前で方向を入力しておく操作が可能になります。
const keyMap = {
ArrowUp: 'up',
KeyW: 'up',
ArrowDown: 'down',
KeyS: 'down',
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right'
};
document.addEventListener('keydown', event => {
if (!keyMap[event.code]) return;
event.preventDefault();
player.next = cloneDir(dirs[keyMap[event.code]]);
});canMoveで次のマスが通路か確認し、予約方向へ進めないときは現在の方向を続けます。壁にぶつかったときに停止し、角へ来たときに予約方向へ曲がる、という操作感をこの分離で作っています。
ゴーストごとに異なる追跡方法を使う
ゴーストは、プレイヤーと同じように1マスずつ移動します。ただし、次の方向を決めるときに、プレイヤーの現在地へ近づく方向を優先します。4体すべてが同じ動きをすると単調になるため、2体にはプレイヤーの前方や横方向にずらした目標地点を与えています。
const target = index === 1
? { x: player.x - player.dir.x * 2,
y: player.y - player.dir.y * 2 }
: index === 2
? { x: player.x + player.dir.y * 3,
y: player.y - player.dir.x * 3 }
: { x: player.x, y: player.y };分岐ごとに目標を変えることで、プレイヤーの正面を狙うゴースト、横から回り込むゴースト、現在地を追うゴーストという違いが生まれます。毎回完全に最短方向だけを選ぶと動きが予測しやすいため、一定確率でランダムな方向を選び、壁際でも同じ場所に固まらないようにしています。
パワーアイテムの効果時間を時刻で管理する
パワーアイテムを取ると、7秒間だけゴーストが青くなり、触れても失敗ではなくボーナスになります。残り時間を毎フレーム減らすのではなく、効果が切れる時刻をpowerUntilに保存し、現在時刻と比較しています。
if (value === 2) {
board[player.y][player.x] = 0;
score += 50;
powerUntil = performance.now() + 7000;
}
const frightened = performance.now() < powerUntil;この形なら、ゲームループの間隔が多少変わっても効果時間を正確に判定できます。ゴーストの色を変える描画処理と、追跡方向を遠ざかる向きにする処理の両方で同じ条件を使えるため、見た目とルールがずれません。
Canvasで盤面とキャラクターを描く
Canvasの描画は、まず画面全体を背景色で塗りつぶし、マップを上から順番に描きます。壁は矩形、ドットとパワーアイテムは円として描画し、その後にプレイヤーとゴーストを重ねます。CanvasはDOM要素をマスごとに作らないため、ゲーム画面を一枚の描画面として扱えます。
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
const px = x * TILE;
const py = y * TILE;
if (MAP[y][x] === '#') {
ctx.fillStyle = '#092b51';
ctx.fillRect(px, py, TILE, TILE);
} else if (board[y][x]) {
ctx.beginPath();
ctx.arc(px + TILE / 2, py + TILE / 2,
board[y][x] === 2 ? 6 : 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
}キャラクターはマスの中心から次のマスの中心までを補間して描画しています。内部の座標は整数のマス位置のままなので判定は簡単で、見た目だけがなめらかに移動します。プレイヤーの口の開閉には時間を使った正弦波を利用し、停止中もアニメーションが表示されます。
一定間隔のゲームループで更新する
ゲームループにはrequestAnimationFrameを使っています。画面の再描画はブラウザに合わせて行い、実際の移動処理はstepTimerが移動間隔を超えたときだけ実行します。描画とゲーム状態の更新を分けることで、画面更新の回数と移動速度を別々に調整できます。
const movementInterval = () =>
Math.max(150, 250 - (level - 1) * 10);
function loop(now) {
if (!running) return;
const elapsed = Math.min(40, now - last);
last = now;
if (!paused) {
stepTimer += elapsed;
const interval = movementInterval();
while (stepTimer >= interval) {
stepTimer -= interval;
tick();
}
}
draw();
if (running) raf = requestAnimationFrame(loop);
}レベルが上がるほどmovementIntervalが短くなり、プレイヤーとゴーストの移動が速くなります。1回のフレームで処理が遅れてもwhileで不足分を補うため、処理落ち後にゲームの時間だけが止まることを防いでいます。
移動、取得、衝突、クリアを1回の更新にまとめる
一定間隔で呼ばれるtickでは、プレイヤーを動かし、ゴーストを動かし、ドットを取得し、最後に衝突を判定します。この順番を一か所にまとめると、「移動したあとに何が起きるか」を追いやすくなります。
function tick() {
movePlayer();
ghosts.forEach(moveGhost);
collect();
collision();
hud();
mouth += 0.35;
}ドットを取ると通常のドットは10点、パワーアイテムは50点です。残りのドットがなくなったらレベルを1増やし、500点にレベルを掛けたクリアボーナスを加えます。短い待ち時間を置いて次の盤面を初期化するため、レベルが切り替わったことも確認できます。
衝突判定と残機を管理する
移動中のキャラクター同士も自然に判定できるよう、現在のマス座標ではなく、補間後のCanvas上の座標を使います。プレイヤーとゴーストの距離が一定値より近ければ衝突です。パワー状態ならゴーストを中央へ戻して200点、通常時なら残機を1つ減らします。
const playerPosition = visual(player, progress);
const ghostPosition = visual(ghost, progress);
const distance = Math.hypot(
playerPosition.x - ghostPosition.x,
playerPosition.y - ghostPosition.y
) / TILE;
if (distance < 0.58) {
if (performance.now() < powerUntil) {
score += 200;
ghost.x = 9;
ghost.y = 9;
} else {
lives--;
resetActors();
}
}残機が0になったらfinishを呼び、ゲームループとBGMを停止します。スコアが過去のベストを超えていれば、localStorageへ保存します。サーバー側のデータベースを使わなくても、このブラウザ内で自己ベストを保持できます。
Web Audio APIでBGMと効果音を作る
BGMと効果音は音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って生成しています。ドット取得、パワーアイテム、ゴースト撃退、失敗など、ゲーム内の出来事ごとに音程や波形を変えています。
function tone(frequency, duration = 0.08,
type = 'square', volume = 0.12,
channel = 'sfx') {
if (!audio || audio.state !== 'running') return;
if (channel === 'sfx' && sfxMuted) return;
if (channel === 'bgm' && bgmMuted) return;
const oscillator = audio.createOscillator();
const gain = audio.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
oscillator.connect(gain).connect(master);
oscillator.start();
oscillator.stop(audio.currentTime + duration);
}ブラウザの自動再生制限に対応するため、AudioContextの初期化はゲーム開始やサウンドボタンのクリックから行います。BGMと効果音のミュート、音量スライダー、一時停止時のBGM停止も同じ音声管理へ集約しています。
実装して分かったこと
メイズマンチャーでは、マインスイーパーのように盤面を配列で管理する考え方が役立ちます。ただし、静的なマスの状態だけでなく、移動中のキャラクター、経過時間、入力の予約、効果時間も同時に扱う必要があります。
特に重要なのは、マップの通行判定、内部座標、Canvas上の表示座標を分けたことです。ゲームのルールは整数のマスで計算し、見た目だけを補間することで、判定の分かりやすさと動きの滑らかさを両立できました。
実際に遊ぶときはメイズマンチャーのページからゲームを開始できます。コードを読む場合は、まずnewBoard、tick、draw、collisionの順に追うと、盤面の準備から1フレームの処理、表示、判定までの流れをつかみやすくなります。