【2025年版】遊びながら学ぶ!JavaScriptで究極のパズルゲーム『倉庫番』を作ろう

目次

はじめに:倉庫番ゲームとは?

倉庫番ゲーム(Sokoban)は、1980年代に日本で生まれたクラシックなパズルゲームです。
プレイヤーがキャラクターを動かし、倉庫に置かれた箱をゴール地点まで押していくというシンプルなルール。

ですがシンプルでありながら、

  • 状態管理
  • 当たり判定
  • 移動制御
    といったプログラミングの要素が凝縮されており、学習教材としても優れています。

本記事では、中級者向けに HTML + CSS + JavaScript を使って、倉庫番ゲームを1から実装する手順を徹底解説します。

使用技術と学習ポイント

  • HTML:ゲーム画面の枠組み(<div> または <canvas>
  • CSS:セルの見た目を整える(壁、箱、ゴールなどを色分け)
  • JavaScript:キャラクター操作、当たり判定、勝敗条件を制御

本記事を通して学べるポイントは:

  • 2次元配列によるマップ設計
  • キーボード操作イベントの処理
  • 当たり判定(壁や箱にぶつかったときの挙動)
  • 状態変化(箱がゴールに乗ったら色を変える)

倉庫番ゲームの構成要素

  1. プレイヤー(キャラ)
    • 自由に動ける
    • 箱を押すことができる
    • プレイヤーが押すと動く
    • ゴール地点に乗るとクリア条件にカウント
    • 通過不可
  2. ゴール
    • 箱を置くべき場所
  3. マップ
    • 上記要素を組み合わせて作成
    • 配列で表現

HTMLとCSSで土台を作る

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>倉庫番ゲーム</title>
  <style>
    body { display: flex; justify-content: center; margin-top: 50px; }
    #game { display: grid; grid-template-columns: repeat(8, 50px); }
    .cell { width: 50px; height: 50px; border: 1px solid #ccc; }
    .wall { background: #333; }
    .player { background: #0000ff; }
    .box { background: #a52a2a; }
    .goal { background: #ffd700; }
    .box-goal { background: #32cd32; }
  </style>
</head>
<body>
<div id="game"></div>
<script src="script.js"></script>
</body>
</html>
  • #gamedisplay: gridを設定することで、マップを格子状に配置します。
  • .cell:全てのマスに共通のサイズを定義します。
  • .wall, .player, .boxなど:それぞれの要素に色や枠線で視覚的な特徴を与えます。
    .box-on-goalは、箱がゴールの上に乗った状態を表現するためのクラスです。

JavaScriptでマップを定義

ゲームのマップは、文字列の配列(2次元配列)で表現します。
各文字は、#(壁)、(床)、$(箱)、@(プレイヤー)、.(ゴール)に対応します。
また、ゴールの位置はゲームクリアの判定に使うため、別の配列に保存しておきます。

// マップの初期配置
let map = [
    "########",
    "#  .   #",
    "#  $   #",
    "#  @   #",
    "########"
];

// ゴールの座標を記録しておく
const goals = [];
map.forEach((row, y) => {
    row.split("").forEach((cell, x) => {
        if (cell === ".") goals.push({ x, y });
    });
});
  • map は2次元配列の文字列版
  • JavaScriptのループ処理を使って、マップの各セルに対応する<div>要素を生成し、適切なクラスを付与してゲーム画面に描画します。

画面を描画する関数

render()関数を作成し、map配列の内容を元に画面を構築します。
この関数はゲームの状態が変化するたびに呼び出され、画面を最新の状態に更新します。

const game = document.getElementById("game");

function render() {
    game.innerHTML = "";
    map.forEach((row, y) => {
        row.split("").forEach((cell, x) => {
            const div = document.createElement("div");
            div.classList.add("cell");

            // ゴール位置は常に背景として描画
            if (goals.some(g => g.x === x && g.y === y)) {
                div.classList.add("goal");
            }

            if (cell === "#") div.classList.add("wall");
            if (cell === "$") {
                // 箱がゴール上にある場合は色を変える
                if (goals.some(g => g.x === x && g.y === y)) {
                    div.classList.add("box-goal");
                } else {
                    div.classList.add("box");
                }
            }
            if (cell === "@") div.classList.add("player");

            game.appendChild(div);
        });
    });
}

render();
  • map 配列を走査して画面を構築
  • これで 壁・ゴール・箱・プレイヤー が表示されます

プレイヤーの移動処理を実装する

キーボードの矢印キー入力に応じて、プレイヤーを動かすロジックを実装します。

document.addEventListener("keydown", e => {
  let dx = 0, dy = 0;
  if (e.key === "ArrowUp") dy = -1;
  if (e.key === "ArrowDown") dy = 1;
  if (e.key === "ArrowLeft") dx = -1;
  if (e.key === "ArrowRight") dx = 1;

  movePlayer(dx, dy);
});

movePlayer関数で当たり判定を処理

function movePlayer(dx, dy) {
  let px, py;
  map.forEach((row, y) => {
    const x = row.indexOf("@");
    if (x !== -1) { px = x; py = y; }
  });

  const target = map[py + dy][px + dx];

  if (target === " " || target === ".") {
    // プレイヤーが移動できる
    map[py] = replaceChar(map[py], px, " ");
    map[py + dy] = replaceChar(map[py + dy], px + dx, "@");
  } else if (target === "$") {
    // 箱を押す処理
    const next = map[py + dy*2][px + dx*2];
    if (next === " " || next === ".") {
      map[py] = replaceChar(map[py], px, " ");
      map[py + dy] = replaceChar(map[py + dy], px + dx, "@");
      map[py + dy*2] = replaceChar(map[py + dy*2], px + dx*2, "$");
    }
  }
  render();
  checkClear();
}

function replaceChar(str, index, char) {
  return str.substring(0, index) + char + str.substring(index + 1);
}
  • 壁にぶつかったら何もしない
  • 箱の先が空いていれば箱を押す
  • render()関数を呼び出して、ゲームの状態が変更されるたびに画面を更新します。

勝利条件をチェックする

すべてのゴールマスに箱が置かれているかを判定する関数を実装します。

function checkClear() {
    let clear = true;
    goals.forEach(goal => {
        // ゴール位置に箱があるかをチェック
        if (map[goal.y][goal.x] !== "$") {
            clear = false;
        }
    });
    if (clear) {
        setTimeout(() => {
            alert("おめでとう!クリアです!");
        }, 100); // 少し遅延させると描画後に表示される
    }
}
  • 全てのゴール . に箱が置かれればクリア
  • アラートで結果を表示

改良ポイント

  • 複数ステージ対応
    配列を切り替えて次のマップへ進める
  • 操作数カウント
    「何手でクリアしたか」を表示
  • UI改善
    • リセットボタン
    • ステージ選択メニュー
  • スマホ対応
    • タッチ操作で移動

完成動画/今回のまとめ

倉庫番ゲームを実装することで、

  • 2次元配列での状態管理
  • 当たり判定
  • プレイヤーとオブジェクトの連動処理

といった中級者に必要なスキルを一通り学ぶことができました。

このシンプルなゲームをベースに、ステージを増やしたり、UIを整えたりすれば、オリジナルの「本格倉庫番ゲーム」に育てていけます。

プログラミング学習の一環として、ぜひ挑戦してみてください!

よくある質問(実装の落とし穴)

  1. 「ゴールの上から箱やプレイヤーが離れたら背景は?」
    → 背景は goals による “別レイヤ描画” なので自動で元通り。map 側に . を戻す必要はなし。
  2. 「箱がゴール上かの見た目はどう変えてる?」
    render() 内で if (c === "$") { if (isGoal(x,y)) div.classList.add("box-goal") ... } によって制御。
  3. 「当たり判定の境界チェックが煩雑にならない?」
    cellAt範囲外 = 壁 を返す設計にしているので、呼び出し側はシンプルに書ける。
  4. 「描画前に alert が出て見た目が更新されない」
    setTimeout(..., 100) の小遅延で先に描画を反映。UI/UX が良くなる。

発展課題(中級者〜)

  • 複数ステージrawMaps = [ [...], [...], ... ]currentStage を持ち、checkClear() で次へ。
  • 操作回数/時間の計測moves++Date.now() の差分でタイム。
  • アンドゥ(1手戻す):操作履歴(プレイヤー位置、箱移動の有無)をスタックで保持。
  • 最短手数探索:BFS/IDA* などの探索アルゴリズム適用(別記事の良ネタ)。
  • 描画最適化:差分レンダリング(前フレームと比較して変更セルのみ差し替え)。

おすすめ本を紹介!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

神奈川県出身
どこにでもいる現役ゲーム会社サーバーエンジニア。大学ではひたすらゲーム!ゲーム!という人生を送ってました。

このブログは「ゲームを作ってみたい!」「プログラミングに関する知識をつけたい!」そんな皆さんの少しでもお役になれば嬉しいなと思い開設しました。

趣味
YouTube鑑賞、ストリートな格闘ゲーム、キャンプ

最近の出来事
・痔主になってしまいました....
・Google Cloud Professional Cloud Architect取得しました。

コメント

コメントする

目次