※本記事にはアフィリエイト広告(Udemy)を含みます。
インストール不要で、PCとスマートフォンから遊べます。
前回のおさらい
前回は、5×5の水路をつなぐパズルゲーム『雨まで10秒』について、企画と技術構成を決めました。
中心となる言語はTypeScriptです。HTMLで画面の土台を作り、CSS Gridで盤面を並べ、パイプと植物はインラインSVGで描画します。
今回は、ゲームの中心となる「パイプ同士がつながっているか」「入口から植物まで水が届くか」を判定する処理を作ります。

今回のゴール
今回作る処理は、次の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段階目へ進むことはありません。
水が通ったことを見た目でも伝える
内部で正しく判定できても、プレイヤーに結果が伝わらなければパズルとして遊びにくくなります。
そこで、水が届いたパイプは青く光らせ、入口からの距離に応じて少しずつ表示しました。接続していない出口は茶色のまま残るため、水が止まった位置も確認できます。

パイプの外形はSVGで描き、回転と接続判定は同じデータを使っています。特にL字とT字は、マスの端まで管を伸ばし、隣のパイプと物理的につながって見えるように調整しました。
動作確認
接続処理は、ブラウザで見ただけでは判断しません。
自動テストでは、次の内容を確認しています。
- I字、L字、T字が回転後の正しい方向へ接続する
- 片側だけが開いているマスへ水が進まない
- 入口とつながっていない水路へ水が広がらない
- 分岐した水路から複数の植物へ水が届く
- 水が届いた植物だけが1段階成長する
- 花まで育った植物が3より大きくならない
画面では、最短7手で最初のステージを解き、3つの植物まで水が通ることも確認しました。
今回のまとめと次回予告
今回は、上下左右の接続を数値で表し、回転後のパイプ同士が両側からつながっているかを判定しました。
さらに、入口から幅優先探索で水を広げ、水が届いた植物だけを成長させました。
次回③では、この仕組みを7つのステージへ広げます。岩、固定パイプ、入口方向、漏水ゼロといったルールを追加し、効果音、進捗保存、スマートフォン対応、自動テストまで仕上げます。
TypeScriptやゲームプログラミングを学ぶオンライン講座
今回使った配列、列挙型、ビット演算、幅優先探索は、パズルゲーム以外の経路探索にも応用できます。
コードを読むだけで分かりにくい場合は、動画講座でJavaScriptやTypeScriptの基本を確認し、小さな盤面で試す方法もあります。
※本リンクはアフィリエイトリンクです。


コメント