JavaScriptとcanvasでスネークゲームを作る。ライブラリなし、実質120行
ゲームエンジンなしでブラウザゲームはどこまで作れるか。canvasのスネークゲームを例に、ゲームループ・入力キュー・当たり判定・ネオン演出の実装を開発ログ形式で解説。遊べるデモつき。
前職はゲーム業界で7年、フロントエンドを書いていた。当時はエンジンやフレームワークの分厚い層の上で仕事をしていたので、「素のJavaScriptとcanvasだけでゲームを作る」というのは、実はやったことがなかった。
先日このサイトにゲームコーナーを作ったとき、最初に書いたのがスネークゲームだった。結論から言うと、ゲームとして成立するまで実質120行くらい。エンジンなしのゲーム開発は、思っていたよりずっと気持ちがいい。完成品はここで遊べるので、先に触ってから読むと話が早いと思う。
全体の構造は4つだけ
スネークに必要なのは結局これだけだった。
- 状態 — ヘビの体(座標の配列)、進行方向、餌の位置、スコア
- ループ — 一定間隔で状態を1ステップ進める
- 入力 — 矢印キーを「次の方向」として受け付ける
- 描画 — 状態をcanvasに絵として吐き出す
ゲームエンジンが普段やってくれていることの、最小構成がこれだ。順番に見ていく。
ループ: requestAnimationFrameを使わなかった理由
ブラウザゲームの定石はrequestAnimationFrameだが、スネークはマス目単位でカクカク進むゲームなので、なめらかな毎フレーム更新が要らない。setIntervalで十分どころか、その方が速度管理が楽になる。
let speed = 150; // ミリ秒。小さいほど速い
function schedule() {
clearInterval(timer);
timer = setInterval(tick, speed);
}
// 餌を食べるたびに加速
speed = Math.max(60, speed - 4);
schedule();
餌を食べたらspeedを縮めてsetIntervalを張り直す。これだけで「進むほど難しくなる」というゲームの背骨ができる。下限を60msにしているのは、それ以上速いと人間がついていけなくなるから。このあたりの数値は理屈より手触りで決めた。
入力: 一番のバグは「反転即死」
スネーク実装で必ず踏むバグがある。右に進んでいるとき、素早く「上→左」と入力すると、1ステップの間に方向が2回変わって、自分の首に頭から突っ込んで死ぬ。プレイヤーからすれば理不尽以外の何物でもない。
対策は、入力を直接反映せず「次の方向」として1つだけ予約すること。
let dir = { x: 1, y: 0 }; // 現在の進行方向
let nextDir = dir; // 予約された次の方向
function setDir(x, y) {
// 真後ろへの反転だけは受け付けない
if (x === -dir.x && y === -dir.y) return;
nextDir = { x, y };
}
function tick() {
dir = nextDir; // ステップの頭で1回だけ反映
// ...
}
ポイントは反転チェックをnextDirではなく現在のdirと比較すること。ここを間違えると連打時にすり抜ける。小さいゲームなのに、入力まわりだけはゲーム業界時代に散々見た「気持ちよさは細部に宿る」がそのまま出てくる。
当たり判定: 配列を舐めるだけでいい
ヘビの体は座標の配列で、先頭が頭。1ステップの処理はこうなる。
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
// 壁か自分にぶつかったら終了
if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS ||
snake.some((s) => s.x === head.x && s.y === head.y)) {
return gameOver();
}
snake.unshift(head); // 頭を足す
if (head.x === food.x && head.y === food.y) {
score += 10; // 食べたら尻尾を縮めない=1マス伸びる
food = spawnFood();
} else {
snake.pop(); // 食べてなければ尻尾を削る=長さ維持
}
「伸びる」の実装がpop()を呼ばないだけ、というのがスネークの美しいところだと思う。物理エンジンも衝突レイヤーも要らない。some()で配列を舐めるだけ。盤面26×18マス程度なら性能の心配も一切ない。
ひとつだけ罠があって、餌の出現位置はヘビの体と重ならないように引き直す必要がある。
function spawnFood() {
let f;
do {
f = { x: (Math.random() * COLS) | 0, y: (Math.random() * ROWS) | 0 };
} while (snake.some((s) => s.x === f.x && s.y === f.y));
return f;
}
演出: shadowBlurだけでネオンになる
このサイトはシンセウェイブ調なので、ヘビも光らせたい。canvasで発光表現というと身構えるが、実はshadowBlurとshadowColorを塗る前に設定するだけでそれっぽくなる。
ctx.shadowColor = '#22e4ff';
ctx.shadowBlur = 8;
ctx.fillStyle = `rgba(34,228,255,${alpha})`; // 尻尾ほど薄く
ctx.fillRect(x, y, CELL - 2, CELL - 2);
体のセルごとにalphaを先頭から減衰させると、残像を引いているように見える。頭だけ色を変えて(うちはピンク)、強めに光らせると視認性も上がる。shadowBlurは重い処理なので多用は禁物だけど、この規模なら気にしなくていい。
あとは細かい味付けとして、餌をMath.sin(Date.now() / 180)で脈打たせている。静止画だと伝わらないので、これも実物を見てほしい。
スマホ対応はスワイプ4行
touchstartとtouchendの座標差を取って、水平/垂直の大きい方に振るだけ。
cv.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - tx;
const dy = e.changedTouches[0].clientY - ty;
if (Math.abs(dx) > Math.abs(dy)) setDir(Math.sign(dx), 0);
else setDir(0, Math.sign(dy));
}, { passive: true });
ハイスコアはlocalStorageに1行で保存。サーバー不要のゲームは、こういうところが本当に楽だ。
エンジンに乗った開発は強力だけど、レイヤーが厚いぶん「ゲームが動く仕組みそのもの」からは遠ざかる。素のcanvasで一度作ってみると、ループ・入力・判定・描画という骨格が裸で見えて、これが結構、仕事のフロントエンド設計にも効いてくる。UIのステート管理とゲームの状態更新は、突き詰めると同じ形をしているので。
次はTOPページに埋め込んだGRID RUNNER(Chrome恐竜のシンセウェイブ版みたいなやつ)の話を書く予定。障害物の間隔調整で「難しすぎる」とダメ出しを食らって直した話も含めて。