メインコンテンツへスキップ
#ゲーム開発 約5分で読めます

JavaScriptとcanvasでスネークゲームを作る。ライブラリなし、実質120行

ゲームエンジンなしでブラウザゲームはどこまで作れるか。canvasのスネークゲームを例に、ゲームループ・入力キュー・当たり判定・ネオン演出の実装を開発ログ形式で解説。遊べるデモつき。

前職はゲーム業界で7年、フロントエンドを書いていた。当時はエンジンやフレームワークの分厚い層の上で仕事をしていたので、「素のJavaScriptとcanvasだけでゲームを作る」というのは、実はやったことがなかった。

先日このサイトにゲームコーナーを作ったとき、最初に書いたのがスネークゲームだった。結論から言うと、ゲームとして成立するまで実質120行くらい。エンジンなしのゲーム開発は、思っていたよりずっと気持ちがいい。完成品はここで遊べるので、先に触ってから読むと話が早いと思う。

全体の構造は4つだけ

スネークに必要なのは結局これだけだった。

  1. 状態 — ヘビの体(座標の配列)、進行方向、餌の位置、スコア
  2. ループ — 一定間隔で状態を1ステップ進める
  3. 入力 — 矢印キーを「次の方向」として受け付ける
  4. 描画 — 状態を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で発光表現というと身構えるが、実はshadowBlurshadowColorを塗る前に設定するだけでそれっぽくなる。

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行

touchstarttouchendの座標差を取って、水平/垂直の大きい方に振るだけ。

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恐竜のシンセウェイブ版みたいなやつ)の話を書く予定。障害物の間隔調整で「難しすぎる」とダメ出しを食らって直した話も含めて。

#JavaScript #canvas #ゲーム開発 #ブラウザゲーム
シェア