マガジン

すごろく・ボードゲームの作り方:マス管理とイベント

遊びをつくる

執筆にClaudeを使用しています。

光の差す窓辺に置かれたオセロ盤とチェス駒。対局のあとを思わせる静かな机。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

すごろくやボードゲームをブラウザで作るとき、最初に詰まるのは「マスをどうデータとして持つか」という設計の問いです。見た目の盤面は画像で描けても、プログラムが「今どのマスにいるか」「そのマスで何が起きるか」を正確に扱えなければゲームになりません。 この記事では、マスの座標管理とイベント設計という二つの核心に絞って、具体的なデータ構造と処理の流れを説明します。HTMLとJavaScriptだけで動く単純なすごろくを作れる水準を出発点にし、イベントの種類を増やしながら複雑なボードゲームへ拡張する道筋も示します。

この記事の目次
  1. マスをデータで表す:配列とオブジェクトの選び方
  2. プレイヤー位置の更新とゴール判定
  3. イベント設計:種類の整理と処理の分離
  4. 複数プレイヤーのターン管理
  5. 描画とデータの分離:再描画を安全にする
  6. 次の行動

マスをデータで表す:配列とオブジェクトの選び方

最も素朴な表現は、マスを一次元配列で並べることです。

js
const board = [
  { id: 0, type: "start" },
  { id: 1, type: "normal" },
  { id: 2, type: "event", eventId: "draw_card" },
  ...
];

プレイヤーの位置は position という数値一つで管理でき、board[position] でそのマスの情報を取れます。すごろく・人生ゲーム型の直線や螺旋コースならこれで十分です。

チェスや将棋のような二次元グリッドが必要な場合は、board[row][col] の二次元配列か、board[row * cols + col] で一次元に変換するかのどちらかを選びます。後者は描画ループが一本で済む利点があります。

グラフ構造(分岐ルートがある)が必要なときは、各マスに next: [id, id] のような隣接先リストを持たせます。

js
const board = {
  0: { type: "start",  next: [1] },
  1: { type: "normal", next: [2, 10] }, // 分岐
  2: { type: "event",  eventId: "lose_turn", next: [3] },
  ...
};

設計の判断基準は「プレイヤーの移動が一本道か、分岐するか」です。直線なら配列、分岐があるならオブジェクトのグラフが扱いやすくなります。最初は直線配列で作り、分岐が必要になったときにグラフへ切り替えるのが現実的な手順です。

プレイヤー位置の更新とゴール判定

サイコロを振ってコマを進める処理は、次の三つの段階に分けると整理しやすくなります。

  1. ダイスを振る:Math.floor(Math.random() * 6) + 1 で1〜6の整数を得ます。
  2. 位置を更新する:newPosition = currentPosition + diceResult を計算し、ゴールを超えた場合の処理(ちょうど止まらないとゴールできないルールなど)を加えます。
  3. マスイベントを発火する:新しい位置のマスデータを読み、type や eventId に応じた処理を呼びます。

ゴール超過の扱いは意外と見落とされます。「超えたら跳ね返る」ルールなら以下のように書けます。

js
if (newPosition > lastIndex) {
  newPosition = lastIndex - (newPosition - lastIndex);
}

「ちょうどでなければ進めない」ルールなら位置を更新せず、メッセージだけ表示します。どちらのルールにするかを最初に決めておくと、後で条件分岐が増えません。

イベント設計:種類の整理と処理の分離

マスイベントは「何が起きるか」を文字列のIDで管理し、実際の処理を別の関数に分離するのが拡張しやすい構造です。

js
const eventHandlers = {
  lose_turn:  (player) => { player.skipTurns += 1; },
  move_back:  (player) => { player.position -= 3; },
  draw_card:  (player) => { drawCard(player); },
  get_bonus:  (player) => { player.score += 100; },
};

function landOnSquare(player, square) {
  if (square.eventId && eventHandlers[square.eventId]) {
    eventHandlers[square.eventId](player);
  }
}

このパターンの利点は、新しいイベントを追加するときに eventHandlers にエントリを足すだけで済むことです。landOnSquare 本体を書き換える必要がありません。

イベントの種類を整理すると、大きく四つに分類できます。

  • 移動系、具体例:〇マス進む・戻る、指定マスへ飛ぶ、プレイヤー状態への影響:position の変更
  • 状態系、具体例:1回休み、もう一度振る、プレイヤー状態への影響:skipTurns などのフラグ
  • リソース系、具体例:スコア増減、アイテム取得、プレイヤー状態への影響:score・items の変更
  • インタラクション系、具体例:カードを引く、選択肢を選ぶ、プレイヤー状態への影響:上記の組み合わせ+UI表示

カード引きや選択肢のように「プレイヤーの入力を待つ」イベントは、非同期処理(Promise や async/await)が必要になります。まずは入力を待たない移動系・状態系から実装し、動作確認してからインタラクション系へ進むと詰まりにくくなります。

複数プレイヤーのターン管理

プレイヤーが複数いる場合、ターンの管理は「現在のプレイヤーインデックス」一つで表現できます。

js
let currentPlayerIndex = 0;

function endTurn() {
  currentPlayerIndex = (currentPlayerIndex + 1) % players.length;
  // skipTurns が残っているプレイヤーをスキップ
  while (players[currentPlayerIndex].skipTurns > 0) {
    players[currentPlayerIndex].skipTurns -= 1;
    currentPlayerIndex = (currentPlayerIndex + 1) % players.length;
  }
}

skipTurns の消費タイミングを「ターン開始時に1減らして、0より大きければスキップ」とするか「スキップする際に消費する」とするかでルールが変わります。どちらかを明示的に選んでから実装してください。後から変えると他の処理に影響が出やすい部分です。

AIプレイヤーを加えるときは、ダイスを振る処理を takeTurn(player) という関数にまとめ、人間プレイヤーならボタン押下を、AIプレイヤーなら自動呼び出しを使うように分岐させます。関数の外から見たインターフェースを同じにしておくと、後から人数や人間・AI比率を変えやすくなります。

描画とデータの分離:再描画を安全にする

マスの状態が変わるたびに画面全体を再描画する方法と、変化した部分だけ更新する方法があります。Canvas を使う場合は clearRect で全消去してから描き直す全体再描画が単純です。DOM(HTML要素)で盤面を作る場合は、各マスに対応する要素を更新する方が効率的です。

重要な原則は「ゲームのデータと表示を分ける」ことです。players 配列と board 配列にゲームの状態をすべて保持し、render() 関数はその状態を読んで画面を更新するだけにします。この構造を守ると、「描画がおかしい」バグと「ロジックがおかしい」バグを切り分けて調べられるようになります。

次の行動

まず一次元配列でマスを定義し、ダイスで位置を更新して type を console.log で確認するだけの最小版を動かしてください。イベントハンドラの追加は、その最小版が正しく動いてから始めるのが遠回りに見えて確実です。完成したゲームは ZIPファイルにまとめてアソバースへ投稿できます。