ブラウザ版ネオンテリトリーの実装を解説
Canvasの盤面描画、光のラインによる領域確保、到達可能領域の探索、敵との衝突判定、ステージ進行まで、ゲームページの実装を順番に紹介します。
ネオンテリトリーは、安全地帯から線を伸ばしてフィールドを分割し、暴走コアを避けながら領域を確保する陣取りゲームです。プレイヤーが動くたびに盤面の状態が変わり、線を安全地帯へつないだ瞬間に、敵を含まない側の領域を塗りつぶします。
今回のゲームは、PHPで共通ヘッダーや広告を読み込み、ゲーム画面はCanvasとJavaScriptで実装しています。盤面のマス、プレイヤー、敵、描画中の線、パーティクルをJavaScriptの状態として管理し、requestAnimationFrameによるゲームループで更新と描画を繰り返します。
盤面を二次元配列で管理する
盤面は30列×38行の二次元配列です。外周は最初から確保済みの領域にし、内部は未確保の領域として初期化します。セルの値は、0を未確保、1を確保済み、2を現在描画中のラインとして使い分けます。
const C = 30, R = 38, S = 20;
let grid, player, cores = [], trail = [];
function resetBoard() {
grid = Array.from({ length: R }, (_, y) =>
Array.from({ length: C }, (_, x) =>
x === 0 || y === 0 || x === C - 1 || y === R - 1 ? 1 : 0
)
);
player = {
x: Math.floor(C / 2),
y: R - 1,
drawing: false,
inv: 1
};
trail = [];
}Sは1マスの大きさです。Canvas上の座標はマスの座標にSを掛けて変換します。ゲーム内部では整数のマスで判定するため、敵の移動や線との衝突を安定して処理できます。
プレイヤーの移動と線の状態を分ける
プレイヤーが確保済みの領域から未確保の領域へ出ると、drawingをtrueにします。移動したセルを2に変更し、座標をtrailへ追加します。再び確保済みのセルへ戻ったら、線を閉じて領域確保の処理へ進みます。
function movePlayer() {
if (!nextDir) return;
dir = nextDir;
const [dx, dy] = dirs[dir];
const nx = player.x + dx;
const ny = player.y + dy;
if (nx < 0 || ny < 0 || nx >= C || ny >= R) return;
const target = grid[ny][nx];
if (player.drawing && target === 2) {
loseLife();
return;
}
if (!player.drawing && target === 0) {
player.drawing = true;
}
player.x = nx;
player.y = ny;
if (player.drawing) {
if (target === 1) {
capture();
return;
}
grid[ny][nx] = 2;
trail.push([nx, ny]);
}
}描画中の線へ戻るとライフを失うため、同じ方向へ進み続けて線を横切る操作も検出できます。確保済みの領域へ戻ったときは、プレイヤーの位置を含む線を確定させる前にcaptureを呼び出します。
敵がいない側を探索して領域を確保する
線で囲まれた領域のうち、敵を含む側は残し、敵がいない側だけを確保します。この判定には、敵の現在位置から未確保セルをたどる幅優先探索を使います。敵から到達できるセルをreachableに記録し、最後に到達できなかった未確保セルを1へ変更します。
const reachable = Array.from(
{ length: R },
() => Array(C).fill(false)
);
const queue = [];
for (const core of cores) {
const x = Math.floor(core.x);
const y = Math.floor(core.y);
if (grid[y][x] === 0) {
reachable[y][x] = true;
queue.push([x, y]);
}
}
for (let i = 0; i < queue.length; i++) {
const [x, y] = queue[i];
for (const [dx, dy] of Object.values(dirs)) {
const nx = x + dx;
const ny = y + dy;
if (nx > 0 && ny > 0 && nx < C - 1 && ny < R - 1 &&
!reachable[ny][nx] && grid[ny][nx] === 0) {
reachable[ny][nx] = true;
queue.push([nx, ny]);
}
}
}敵のいる場所を始点にしているのがポイントです。敵から到達できる領域はそのまま残し、到達できないセルだけを確保するため、線のどちら側に敵がいるかを個別に計算する必要がありません。
for (let y = 1; y < R - 1; y++) {
for (let x = 1; x < C - 1; x++) {
if (grid[y][x] === 2 ||
(grid[y][x] === 0 && !reachable[y][x])) {
grid[y][x] = 1;
gained++;
}
}
}
trail = [];
player.drawing = false;
score += gained * 10 * stage;
calculateArea();描画中だった2のセルも確保済みの1へ変えます。確保したマス数をgainedとしてスコアに反映し、同時に確保率を再計算します。
敵の移動とラインへの衝突を調べる
暴走コアは小数の座標と速度を持ち、毎フレーム少しずつ移動します。壁や確保済み領域へ進もうとした場合は、その方向の速度を反転させます。移動後の周辺セルに描画中の線があれば、プレイヤーのミスとして扱います。
function updateCores(dt) {
for (const core of cores) {
let nx = core.x + core.vx * dt;
let ny = core.y + core.vy * dt;
if (blocked(nx, core.y)) {
core.vx *= -1;
nx = core.x + core.vx * dt;
}
if (blocked(core.x, ny)) {
core.vy *= -1;
ny = core.y + core.vy * dt;
}
core.x = nx;
core.y = ny;
const gx = Math.floor(core.x);
const gy = Math.floor(core.y);
for (let y = gy - 1; y <= gy + 1; y++) {
for (let x = gx - 1; x <= gx + 1; x++) {
if (grid[y]?.[x] === 2) loseLife();
}
}
}
}敵の座標はセルの中央とは限らないため、Math.floorで現在セルを求め、その周囲1マスまでを確認しています。敵の見た目を大きく描いても、ゲーム上の判定範囲を調整しやすい構成です。
経過時間を使ったゲームループ
ゲームループでは、前フレームからの経過時間を秒に変換してupdateへ渡します。画面の更新速度が端末ごとに違っても、時間に応じた移動量を計算できるため、速い端末だけ敵が速く動く問題を抑えられます。
function loop(now) {
if (!running) return;
const dt = Math.min(.035, (now - last) / 1000);
last = now;
if (!paused) update(dt);
draw();
if (running) {
raf = requestAnimationFrame(loop);
}
}極端に長いフレームが発生した場合は、dtを0.035秒に制限しています。タブを切り替えたあとに大きくワープするのを防ぐためです。ポーズ中もdrawは続けるので、画面を残したまま操作を止められます。
Canvasへ盤面と演出を描画する
drawでは、背景、確保済みセル、描画中の線、グリッド、暴走コア、プレイヤー、パーティクルの順番に描きます。Canvasの影や発光を使い、ゲーム状態は同じでも、確保済み領域と危険な線を見分けやすくしています。
for (let y = 0; y < R; y++) {
for (let x = 0; x < C; x++) {
if (grid[y][x] === 1) {
ctx.fillStyle = '#183943';
ctx.fillRect(x * S, y * S, S, S);
} else if (grid[y][x] === 2) {
ctx.fillStyle = '#ffe66d';
ctx.shadowColor = '#ffe66d';
ctx.shadowBlur = 10;
ctx.fillRect(x * S + 7, y * S + 7, 6, 6);
ctx.shadowBlur = 0;
}
}
}影を使った描画のあとにshadowBlurを0へ戻しています。Canvasの描画状態は次の図形にも引き継がれるため、リセットを忘れるとグリッドや文字まで発光してしまいます。
ステージ進行と難易度を調整する
ステージは全30段階です。ステージが進むほど暴走コアの数を増やし、速度も上げています。共通のresetBoardで盤面を作り直すため、ステージごとに初期化処理を複製する必要はありません。
const TOTAL = 30;
function resetBoard() {
// 盤面とプレイヤーを初期化する処理
cores = [];
const count = Math.min(
1 + Math.floor((stage - 1) / 2),
4
);
for (let i = 0; i < count; i++) {
cores.push({
x: C * (.35 + .3 * (i % 2)),
y: R * (.32 + .28 * Math.floor(i / 2)),
vx: (i % 2 ? 1 : -1) * (2.5 + stage * .16),
vy: (i % 3 ? 1 : -1) * (2.2 + stage * .13)
});
}
}敵の数は最大4体に抑えています。盤面の大きさを変えずに難易度を上げられる一方、敵を増やしすぎると線を引く前に衝突しやすくなるため、数と速度を別々に調整しています。
キーボードとタッチ操作を同じ状態へ集約する
キーボードではキーを押している間だけ移動し、タッチ操作ではボタンのポインターイベントを使います。入力方法は違っても、最終的にはpressDirectionとreleaseDirectionへ方向名を渡すだけにしています。
const keyMap = {
ArrowUp: 'up', KeyW: 'up',
ArrowDown: 'down', KeyS: 'down',
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right'
};
document.addEventListener('keydown', event => {
const direction = keyMap[event.code];
if (!direction) return;
event.preventDefault();
pressDirection(event.code, direction);
});
document.addEventListener('keyup', event => {
if (keyMap[event.code]) {
releaseDirection(event.code);
}
});押下中の方向はheldに保存し、キーを離したときに別のキーがまだ押されていれば、その方向へ切り替えます。これにより、上下左右を連続して押し替える操作でも入力が途切れにくくなります。
効果音とレスポンシブ対応
効果音はWeb Audio APIの発振器で生成しています。ゲーム開始や線を引いているとき、領域確保、ミスなどで周波数と波形を変え、音声ファイルを別途読み込まずにゲームの反応を表現しています。ブラウザの自動再生制限があるため、音声コンテキストは開始ボタンや操作ボタンを押したあとに作成します。
function tone(freq, duration = .08, type = 'sine', volume = .055) {
if (muted) return;
audio = audio || new AudioContext();
const oscillator = audio.createOscillator();
const gain = audio.createGain();
const time = audio.currentTime;
oscillator.type = type;
oscillator.frequency.value = freq;
gain.gain.setValueAtTime(.0001, time);
gain.gain.exponentialRampToValueAtTime(volume, time + .01);
gain.gain.exponentialRampToValueAtTime(.0001, time + duration);
oscillator.connect(gain).connect(audio.destination);
oscillator.start(time);
oscillator.stop(time + duration + .02);
}Canvasは画面幅に合わせてCSSで縮小し、ゲーム内部の600×760という座標系は維持しています。スマートフォンでは方向ボタンを表示し、マウスやキーボードがなくても遊べるようにしています。ゲーム画面と右側の情報パネルは狭い画面で縦並びへ切り替わります。
実装して分かったこと
ネオンテリトリーでは、線を引く操作そのものよりも、敵から到達できる領域を正しく残す処理が重要です。盤面を単なる見た目として扱わず、未確保、確保済み、描画中という状態を分けたことで、領域確保、衝突判定、確保率計算を同じデータから実行できました。
また、ゲームループではプレイヤーの移動、敵の更新、パーティクルの寿命、Canvas描画を分けて考えることが大切です。実際にネオンテリトリーを遊ぶときは、最初は敵から離れた場所で短い線を引き、確保率と敵の動きを確認してみてください。コードを読むときは、まずresetBoard、movePlayer、capture、updateCores、loopの5つを追うと、ゲーム全体の流れをつかみやすくなります。