ブラウザでパズルゲームを作る②:パイプの接続と水の到達判定

水を流す前の5×5パイプ盤面 初心者向け実践開発

※本記事にはアフィリエイト広告(Udemy)を含みます。

インストール不要で、PCとスマートフォンから遊べます。

前回のおさらい

前回は、5×5の水路をつなぐパズルゲーム『雨まで10秒』について、企画と技術構成を決めました。

中心となる言語はTypeScriptです。HTMLで画面の土台を作り、CSS Gridで盤面を並べ、パイプと植物はインラインSVGで描画します。

今回は、ゲームの中心となる「パイプ同士がつながっているか」「入口から植物まで水が届くか」を判定する処理を作ります。

水を流す前の5×5パイプ盤面

今回のゴール

今回作る処理は、次の4つです。

  • I字、L字、T字などが持つ接続方向を表す
  • パイプの回転を接続方向へ反映する
  • 隣り合う2つのパイプが本当につながっているか確認する
  • 入口から到達できるマスを調べ、植物を成長させる

見た目だけを回転させても、ゲームの判定はできません。画面に描くパイプと、内部で持つ接続情報を同じ回転状態から作ることが重要です。

上下左右を4つの値で表す

最初に、上下左右をTypeScriptのenumで表します。

enumは、決まった候補に名前を付けて並べるTypeScriptの列挙型です。

export enum Direction {
  N = 0,
  E = 1,
  S = 2,
  W = 3,
}

Nは上、Eは右、Sは下、Wは左です。0から3までを時計回りに並べているため、90度回転する処理を書きやすくなります。

接続の有無は、方向ごとに1、2、4、8という値を割り当てました。

const DIRECTION_MASKS: Record<Direction, number> = {
  [Direction.N]: 1,
  [Direction.E]: 2,
  [Direction.S]: 4,
  [Direction.W]: 8,
};

この4つは2進数で異なる桁を使う値です。複数の方向を一つの数値へまとめても、あとからどの方向が含まれているかを確認できます。

パイプごとの接続方向を決める

回転していない状態の接続方向を、パイプの種類ごとに定義します。

const BASE_CONNECTIONS: Record<TileType, number> = {
  straight:
    DIRECTION_MASKS[Direction.N] |
    DIRECTION_MASKS[Direction.S],
  curve:
    DIRECTION_MASKS[Direction.N] |
    DIRECTION_MASKS[Direction.E],
  t:
    DIRECTION_MASKS[Direction.N] |
    DIRECTION_MASKS[Direction.E] |
    DIRECTION_MASKS[Direction.S],
  deadend: DIRECTION_MASKS[Direction.N],
  empty: 0,
  seed: DIRECTION_MASKS[Direction.N],
  rock: 0,
};

ここでstraightはI字、curveはL字、tはT字、deadendは行き止まりです。

例えばI字の初期状態は上と下、L字は上と右へ接続します。空きマスと岩は水を通さないので0です。

|はビット単位のOR演算です。上が1、下が4なら、I字の接続情報は1 | 4で5になります。

回転後の接続方向を求める

前回は、クリックするたびにrotationを0、1、2、3と変化させました。今回は、その回転数を接続方向へ反映します。

export function rotateDirection(
  direction: Direction,
  steps: number,
): Direction {
  const index = DIRECTIONS.indexOf(direction);
  return DIRECTIONS[(index + steps) % 4]!;
}

例えば上向きの接続を1回回転させると右、2回なら下、3回なら左になります。4回転すると% 4によって上へ戻ります。

パイプが現在持っている接続方向は、初期状態の接続を一つずつ回転させて作ります。

export function getConnectionMask(tile: Tile): number {
  const base = BASE_CONNECTIONS[tile.type];
  let mask = 0;

  for (const direction of DIRECTIONS) {
    const bit = DIRECTION_MASKS[direction];
    if ((base & bit) !== 0) {
      const rotated = rotateDirection(direction, tile.rotation);
      mask |= DIRECTION_MASKS[rotated];
    }
  }

  return mask;
}

この処理を通すことで、SVGの見た目とゲーム内部の接続判定を同じrotationから作れます。

コード内の&はビット単位のAND演算です。特定の方向を表すビットが含まれているかを調べています。

隣のパイプもこちらを向いているか確認する

水路の判定で注意したのは、一方のパイプだけを見ないことです。

現在のマスが右へ開いていても、右隣のマスが左へ開いていなければ、水は通りません。

そこで、移動先を調べるときは反対方向の接続も確認します。

const neighbor = board[nextRow]?.[nextCol];

if (!neighbor || !hasConnection(neighbor, OPPOSITE[dir])) {
  continue;
}

dirが右なら、OPPOSITE[dir]は左です。上下の場合も同じ考え方です。

L字やT字のパイプが近くに並ぶと、絵としては接しているように見えても、内部ではつながっていない場合があります。両側を確認することで、見た目に惑わされない判定になります。

入口から水を広げる

次に、水の入口から到達できるマスを順番に調べます。

今回は幅優先探索という方法を使いました。難しそうな名前ですが、入口から近いマスを調べ、そこからつながる次のマスを待ち行列へ入れていく処理です。

const distances = new Map<string, number>();
const queue = [
  { row: entry.row, col: entry.col, distance: 0 },
];
distances.set(`${entry.row},${entry.col}`, 0);

while (queue.length > 0) {
  const current = queue.shift();
  if (!current) break;

  for (const dir of DIRECTIONS) {
    const currentTile = board[current.row]?.[current.col];
    if (!currentTile || !hasConnection(currentTile, dir)) {
      continue;
    }

    const delta = DELTA[dir];
    const nextRow = current.row + delta.row;
    const nextCol = current.col + delta.col;

    if (
      nextRow < 0 ||
      nextRow >= BOARD_SIZE ||
      nextCol < 0 ||
      nextCol >= BOARD_SIZE
    ) {
      continue;
    }

    const key = `${nextRow},${nextCol}`;

    if (distances.has(key)) {
      continue;
    }

    const neighbor = board[nextRow]?.[nextCol];
    if (!neighbor || !hasConnection(neighbor, OPPOSITE[dir])) {
      continue;
    }

    const distance = current.distance + 1;
    distances.set(key, distance);
    queue.push({ row: nextRow, col: nextCol, distance });
  }
}

入口は距離0として最初にMapへ記録します。その後も一度確認したマスを記録し、同じ場所を何度も調べないようにしています。T字で水路が分岐しても、輪のようにつながっても処理が終わります。

各マスには入口からの距離も保存しました。この距離は、水が入口から順番に流れて見えるアニメーションにも利用できます。

水が届いた植物だけを成長させる

植物は、種、芽、つぼみ、花の4段階です。内部では0から3で表しています。

水が届いた植物の番号だけを受け取り、1段階ずつ進めます。

export function advanceGrowthStages(
  stagesBefore: GrowthStage[],
  wateredIndices: number[],
): GrowthStage[] {
  const next = [...stagesBefore] as GrowthStage[];

  for (const index of normalizeWateredIndices(
    stagesBefore,
    wateredIndices,
  )) {
    if (next[index] !== undefined && next[index] < MAX_GROWTH_STAGE) {
      next[index] = (next[index] + 1) as GrowthStage;
    }
  }

  return next;
}

normalizeWateredIndices()では、同じ植物の重複と範囲外の番号を取り除いています。

元の配列を直接書き換えず、コピーしたnextを更新しています。給水前と給水後の状態を分けて持てるため、得点計算や画面更新が分かりやすくなります。

花まで育った値は3で止めます。連続して水を届けても、存在しない5段階目へ進むことはありません。

水が通ったことを見た目でも伝える

内部で正しく判定できても、プレイヤーに結果が伝わらなければパズルとして遊びにくくなります。

そこで、水が届いたパイプは青く光らせ、入口からの距離に応じて少しずつ表示しました。接続していない出口は茶色のまま残るため、水が止まった位置も確認できます。

入口から3つの植物まで水が通り、7手でクリアした画面

パイプの外形はSVGで描き、回転と接続判定は同じデータを使っています。特にL字とT字は、マスの端まで管を伸ばし、隣のパイプと物理的につながって見えるように調整しました。

動作確認

接続処理は、ブラウザで見ただけでは判断しません。

自動テストでは、次の内容を確認しています。

  • I字、L字、T字が回転後の正しい方向へ接続する
  • 片側だけが開いているマスへ水が進まない
  • 入口とつながっていない水路へ水が広がらない
  • 分岐した水路から複数の植物へ水が届く
  • 水が届いた植物だけが1段階成長する
  • 花まで育った植物が3より大きくならない

画面では、最短7手で最初のステージを解き、3つの植物まで水が通ることも確認しました。

今回のまとめと次回予告

今回は、上下左右の接続を数値で表し、回転後のパイプ同士が両側からつながっているかを判定しました。

さらに、入口から幅優先探索で水を広げ、水が届いた植物だけを成長させました。

次回③では、この仕組みを7つのステージへ広げます。岩、固定パイプ、入口方向、漏水ゼロといったルールを追加し、効果音、進捗保存、スマートフォン対応、自動テストまで仕上げます。

TypeScriptやゲームプログラミングを学ぶオンライン講座

今回使った配列、列挙型、ビット演算、幅優先探索は、パズルゲーム以外の経路探索にも応用できます。

コードを読むだけで分かりにくい場合は、動画講座でJavaScriptやTypeScriptの基本を確認し、小さな盤面で試す方法もあります。

はじめてのTypeScriptプログラミング入門を確認する

※本リンクはアフィリエイトリンクです。

コメント

タイトルとURLをコピーしました