光をつなぐパズルを作る:鏡の反射から一面の完成まで
遊びをつくる執筆にClaudeを使用しています。

鏡を置いて光の道を作る──そのシンプルさの裏には、反射の計算、解の存在の保証、描画と判定の一致という3つの設計課題があります。「プリズムの道」のような光パズルを自作しようとすると、どの鏡をどの向きに置けばクリアになるかを正しく判定できるか、そして「解けるステージ」をどう作れるかという問いにすぐぶつかります。この記事ではグリッド上の光線を追うロジックを段階的に組み立て、1面として成立するところまでを順に説明します。コードは説明用の例示であり、動作保証を意図したものではありません。
この記事の目次
光パズルを成り立たせる3つの要素
光パズルの核心を整理すると、必要な要素は3つです。光線の出発点と初期方向、光の進路を変える鏡、そして光が届くべきゴールです。この3つを正しく連動させることが、すべての設計の出発点になります。
- 光源:盤面の端にあり、固定された方向へ光を射出する。プレイヤーは動かせない。
- 鏡:グリッドのマス目に置かれ、「/」か「\」の2向きで光を90度曲げる。プレイヤーが回転させるパーツ。
- ゴール(灯台):盤面の端または固定位置にあり、指定された色の光が届いたときにクリアを認める。
シンプルな実装では色を省き、光が全ゴールに届けばクリアとするだけでも一面は成立します。色の一致判定は後から拡張できるため、まず「光線がゴールに到達するか」を正確に判定することに集中するとよいでしょう。
グリッドと光線のデータ構造
グリッドは2次元配列で表現するのが最も扱いやすい方法です。各マスには「空」「鏡(方向付き)」「ゴール」「壁」のいずれかを格納します。光線はある時点での位置と進行方向の組として表現します。方向は上下左右の4種類をオブジェクトや定数で管理します。
// セルの種類(説明用)
const EMPTY = 0;
const MIRROR_SLASH = 1; // /
const MIRROR_BACKSLASH = 2; // \
const GOAL = 3;
const WALL = 4;
// 方向定数
const DIR = {
RIGHT: { dr: 0, dc: 1 },
LEFT: { dr: 0, dc: -1 },
DOWN: { dr: 1, dc: 0 },
UP: { dr: -1, dc: 0 },
};
// グリッド例(5×5)
const grid = [
[WALL, WALL, WALL, WALL, WALL],
[WALL, EMPTY, EMPTY, EMPTY, GOAL],
[WALL, EMPTY, MIRROR_SLASH, EMPTY, WALL],
[WALL, EMPTY, EMPTY, EMPTY, WALL],
[WALL, WALL, WALL, WALL, WALL],
];盤面の外周をWALLで囲むと、光線が盤外へ出たかどうかを境界チェックなしに判定できます。座標の範囲外参照を防ぐ簡便な方法です。
光源はグリッドの外部情報として別に持ちます。「行2・列0のマスから右方向へ射出」のように、出発セルと初期方向の配列として記述すると、複数光源のステージにも同じ追跡ロジックを使いまわせます。
鏡の反射計算:/と\の向き変換
光パズルの実装で最初につまずきやすい箇所が、鏡の向きと方向変換の対応表です。「/」鏡と「\」鏡それぞれについて、入射方向ごとに反射後の方向を決めておけば、判定ループの中で毎回条件分岐するだけです。
// 反射変換(説明用)
function reflect(dir, mirrorType) {
if (mirrorType === MIRROR_SLASH) {
// / 鏡
if (dir === DIR.RIGHT) return DIR.UP;
if (dir === DIR.LEFT) return DIR.DOWN;
if (dir === DIR.UP) return DIR.RIGHT;
if (dir === DIR.DOWN) return DIR.LEFT;
}
if (mirrorType === MIRROR_BACKSLASH) {
// \ 鏡
if (dir === DIR.RIGHT) return DIR.DOWN;
if (dir === DIR.LEFT) return DIR.UP;
if (dir === DIR.UP) return DIR.LEFT;
if (dir === DIR.DOWN) return DIR.RIGHT;
}
return dir; // 空マスはそのまま直進
}この変換規則は紙に鏡と矢印を描いて確かめると分かりやすくなります。「右から進んできた光がスラッシュ(/)に当たれば上へ、バックスラッシュ(\)に当たれば下へ」というのが核心で、残りの向きも同様に導けます。実装前に4方向×2種の対応表を手書きで一度確認しておくことを勧めます。
無限ループへの対処も必要です。鏡の配置によっては光が同じセルを繰り返し通ることがあります。訪問済みの「セル+方向」の組を集合として記録し、同じ状態に再び入ったら追跡を打ち切る処理を追加してください。最大ステップ数で打ち切る方法でも動きますが、訪問済み管理のほうが見落としが少ない傾向があります。
クリア判定:全ゴールへの到達を確認する
光線の追跡ループは単純です。現在位置から方向に沿って1マスずつ進み、セルの種類に応じて処理を分岐します。ゴールに到達したら「到達済みゴール」として記録し、壁または訪問済み状態になったらループを終了します。全光源の追跡が終わった後、すべてのゴールが記録されていればクリアです。
// 光線の追跡(説明用)
function traceRay(grid, startRow, startCol, startDir) {
const reached = new Set(); // 到達したゴールのセル座標
const visited = new Set();
let r = startRow, c = startCol, dir = startDir;
while (true) {
r += dir.dr;
c += dir.dc;
const key = `${r},${c},${dir.dr},${dir.dc}`;
if (visited.has(key)) break;
visited.add(key);
const cell = grid[r]?.[c];
if (cell === undefined || cell === WALL) break;
if (cell === GOAL) { reached.add(`${r},${c}`); break; }
if (cell === MIRROR_SLASH || cell === MIRROR_BACKSLASH) {
dir = reflect(dir, cell);
}
}
return reached;
}
// 全ゴール到達チェック
function isClear(grid, sources, goals) {
const allReached = new Set();
for (const { row, col, dir } of sources) {
traceRay(grid, row, col, dir).forEach(g => allReached.add(g));
}
return goals.every(g => allReached.has(`${g.row},${g.col}`));
}色の一致判定が必要な場合は、各光源に色を持たせ、追跡中に色情報を引き継ぎます。色ガラスで色が変わる仕様であれば、セルの種類を「赤ガラス」「青ガラス」なども追加し、通過時に現在の色を変換するステップを差し込みます。基本構造を変えずに拡張できるのがこのアプローチの利点です。
クリア判定は「鏡を回転させるたびに再実行する」設計にしておくと、プレイヤーのアクションとフィードバックのタイミングが自然に合います。盤面が小さければ毎回全追跡しても処理は軽いため、差分計算は最初は不要です。
解が存在するステージを先に作る
「解けないステージを誤って配布してしまった」という事故を防ぐ最も確実な方法は、
答えを先に決めてからステージを作る「フォワード生成」です。ランダムに鏡を置いて解の存在を検証する逆順ではなく、完成状態から逆算してパズルを組み立てます。
- グリッドを用意し、光源とゴールの位置・方向・色を決める。
- 光源からゴールまでの経路を手動または乱数で引く。経路は折れ曲がれるが、折れ曲がる箇所が鏡の候補になる。
- 折れ曲がり箇所に鏡を配置する。向きは反射計算から逆算して決定する。
- 完成状態で isClear() を実行し、クリアを確認する。
- クリアを確認した後、鏡をランダムな向きに変えてプレイヤーの初期配置を作る。鏡の枚数・位置は変えない。
- 難易度調整として、一部の鏡を向きが判明した状態で固定(回転不可)にするか、鏡をさらに追加してダミー経路を作る。
この手順で作れば「少なくとも1つの正解が存在する」ことが保証されます。ステージを増やす段階では、完成経路のパターンを変えるだけで枚数を稼げます。グリッドサイズと折れ曲がり回数の組み合わせで難易度のバリエーションを体系的に管理できるようになります。
ステージをファイルや配列として保持する場合は、データに「最小回転数」を記録しておくと後からヒント機能を実装しやすくなります。フォワード生成の時点で使った経路が最短解に相当するため、その鏡の並びを答えデータとして保存するのが自然な方法です。
描画と判定を一致させる確認の手順
光パズルで描画と判定がずれると、「画面では光が届いていないのにクリア判定が出る」あるいは「明らかに繋がっているのにクリアにならない」という混乱が起きます。この問題は、追跡ログを画面に重ねて表示するデバッグ描画を早い段階で用意することで見つけやすくなります。
確認の手順として、まず追跡ループが返す経路(通過したセルの順序リスト)を、グリッドの描画に重ねて線で表示します。この線と目視で確認できる「光の筋」が一致していれば、描画ロジックが判定ロジックと同じデータを参照しています。一致しない場合は、描画側が独自の座標変換を持っているか、グリッドの原点(左上か左下か)が両者でずれている可能性があります。
- 座標系の統一:ロジックは行列インデックス (row, col)、描画はピクセル座標。変換は1か所だけで行い、追跡ループ内には描画座標を持ち込まない。
- 鏡の向きの同期:プレイヤーが鏡を回転したとき、グリッドの値と描画スプライトの向きを同じタイミングで更新する。どちらかを先に変える実装だとクリック連打時にずれが生じやすい。
- デバッグモードの切り替え:URLパラメータやコンソールフラグで経路の可視化をオン・オフできるようにすると、完成後もステージ検証に使える。
1面が安定して動くようになったら、ステージをデータファイルに分離し、isClear() を使った自動テストをビルド前に走らせると安全です。ステージデータを変更するたびに全ステージの解を確認できるため、編集ミスをリリース前に発見できます。フォワード生成で作った答えデータを期待値として使えば、テスト自体を別に書く手間も小さくなります。
光パズルは「反射計算が正しい」「解が存在する」「描画と判定が一致する」という3つの確信が揃ったとき、初めてプレイヤーに届けられる状態になります。それぞれを独立して確認できる構造にしておけば、ステージを増やしても不安なく進められます。完成した光パズルの遊び方は、実際のゲームで確かめてみてください。



