AIゲーム制作ラボ

Unity光反射パズルの作り方:C#で分岐と循環を扱う

鏡で光を反射させるUnityパズル「灯路」の光路計算を、実際のC#コードで再現します。方向を含む訪問管理、分岐を止めない循環判定、操作時だけの再計算を解説。初公開版の実行サンプルと、12面518配置の検査結果付きです。

Libra

公開 2026年9月21日

この記事の要点3項目

循環の判定にはマスと入射方向を使う。 同じマスでも、方向が違えば処理を続ける。

残り2つの要点を読む
  • 光路計算と描画を分ける。 確定した操作の後に再計算し、結果を画面へ渡す。
  • 公開ゲームのC#コードを配布。 コンソールの15検査で反射・分岐・循環と全518配置を再現できる。
目次11
検証した環境最終確認 2026年9月21日
  • ゲーム本体: Unity 6000.6.0f1 / WebGL / Codex制作
  • 配布コード: Windows / .NET SDK 10.0.401 / 追加NuGetパッケージなし

書いてあるのは、この環境で実際に試した結果です。ツールの仕様や料金は変わるので、重要な判断の前には公式の情報も確認してください。

光の通り道は、マスと入射方向の組で管理します。 Codexで作った反射パズル「灯路」では、光路の計算をUnityの描画から分離しました。これなら画面を立ち上げずに、分岐や循環を含む盤面を確かめられます。

ここではゲーム本体で使ったC#を動かし、光路計算の作り方を追います。対象は上下左右の4方向に進むグリッド型パズル。画面・音・入力を含むゲーム全体の制作手順ではありません。

初公開版v1の灯路の最終面。鏡で反射し、分岐器で分かれた光が複数の灯台へ届く
初公開版v1の灯路。見えている光と、点灯の判定に使う光路は同じ計算結果から作る。

公開版と配布サンプルの違い

公開ゲームは2026年9月21日に4〜12面を作り直し、v1.2で日本語化、v1.3で4面と最終面の分岐を必須にしました。このページの配布サンプルと518配置の数字は初公開版v1です。光路計算のルールは同じですが、現在の盤面と解は異なります。変更理由と検査は後半の改良記録に追記しました。

最初にC#サンプルを動かす

.NET 10 SDKが必要です。配布ページの「アプリのビルド - SDK」から、自分のOSに合うものを選びます。dotnet --list-sdks で10系が表示されることを確認し、光路計算のサンプルZIPを展開します。TouroSample.csproj があるフォルダで、次のコマンドを実行します。

dotnet run --project TouroSample.csproj

追加のNuGetパッケージは使っていません。ZIPを別フォルダへ展開して実行し、PASSが15行出ることを確認しました。出力の末尾は次のとおりです。

配布ZIPを展開して実行した結果成功
12 levels / 518 orientations / 1 solved orientation per level
PASS 12 levels and every mirror orientation
15 checks passed. No graphics or browser performance measured.
  • Board.cs:盤面・反射・光路計算・操作履歴。ゲーム本体と同じコード。
  • Levels.cs:初公開版v1の12面の配置と解の探索。v1.1で改良した盤面やセーブ移行は含みません。
  • Program.cs:反射8方向、分岐、循環などを独立した期待値と照合。
  • TouroSample.csproj:.NETで実行するための設定。

SDKが見つからなければ dotnet --list-sdks、プロジェクトが見つからなければ今のフォルダを確認します。検査で例外が出た場合は、直前のPASSと Program.cs の条件を照合してください。サンプルの15検査と、初公開版のUnityテスト74件は別のものです。v1.1では難易度と移行の検査を加え、Unityテストは84件になりました。

座標と反射のルールを先に固定する

私は、自由角度の鏡を入れず、光が上下左右にだけ進む盤面にしました。光路を整数だけで表せるため、見た目の補間と正解判定を分けやすくなります。

盤面は5列7行。原点は左下で、右が+x、上が+yです。セル番号は y * 5 + x。向きの順番もコードの一部で、Right=0, Down=1, Left=2, Up=3 としています。

入射時の進行方向と、鏡を出る方向
入射鏡 /鏡 \
右へ上へ下へ
下へ左へ右へ
左へ下へ上へ
上へ右へ左へ

Board.Reflect はこの表を2つの式にしています。列挙型の順序を変えると式も壊れるので、反射8通りは表の期待値を直接書いて検査しました。

// Board.Reflectの抜粋。完全なコードはZIPのBoard.cs。
if (tile == Tile.MirrorSlash)
    return (Direction)(3 - (int)incoming);
if (tile == Tile.MirrorBackslash)
    return (Direction)((int)incoming ^ 1);
return incoming;

灯台は光を通し、壁は止めます。分岐器は「直進」と「入ってきた向きから右折」を出します。画面の右へ曲がるとは限りません。たとえば下向きで入れば、右折した枝は左へ進みます。

分岐する光はキューに積んで処理する

Trace() のキューには「このセルから、この方向へ出る光」を入れます。先頭から1つ取り出して隣へ進め、到着したマスで反射や分岐を決めます。

  1. 手順 01

    隣のセルへ

    盤面外なら枝を終了

  2. 手順 02

    到着状態を確認

    同じセル+同じ方向なら終了

  3. 手順 03

    マスの効果

    壁・点灯・反射を処理

  4. 手順 04

    続きをキューへ

    分岐器なら2本積む

終了するのは現在の枝だけ。残りのキューは引き続き処理する。

結果には、描画する辺の集合 Edges、点灯したセルの集合 Lit、全灯台がともったかを示す Complete を返します。辺の両端は小さいセル番号から揃え、往復しても同じ辺は1本にします。

描画の重複除去と、光路の訪問判定は別です。 辺は無向でまとめられますが、計算の途中では方向を失ってはいけません。なお、この実装の辺は壁セルの中心への到達分も含みます。画面では不透明な壁が上に重なります。

「一度通ったマス」で止めると、届くはずの光を消してしまう

訪問済みをセル番号だけで記録すると、同じ分岐器へ別方向から戻った光まで止めてしまいます。再現用の小さな盤面を用意しました。

光源から右へ進んで分岐器に入り、右端で下へ、下辺で左へ、中央で上へ反射して同じ分岐器へ下から戻る。上の灯台には再訪を許すと到達する
配布コードの「same cell from another direction」と同じ盤面。点線は最初に分岐した下向きの枝。

1回目は (2,2) の分岐器へ右向きに入ります。直進した光は (4,2) → (4,0) → (2,0) の鏡を通り、同じ分岐器へ今度は上向きで戻ります。この2回目を処理すると、上の (2,4) にある灯台へ届きます。

正しい訪問キーは、次の形です。

int arrival = next * 4 + (int)direction;
if (visited[arrival]) continue;
visited[arrival] = true;

記事用の検証では、コピーしたコードの1行int arrival = next; に変えて実行しました。循環するだけの検査は通るのに、別方向から再訪する検査で止まります。

セルだけを訪問キーにした試作の出力(抜粋)エラー
PASS cycle terminates without losing another branch
Unhandled exception. System.Exception: arrival direction was lost

これは開発中に実際に出たバグとして書き直したものではなく、判定の違いを確かめるために今回作った比較用の誤実装です。配布ZIPには正しい版だけを入れています。

循環しても、ほかの枝の計算は続ける

訪問状態は35セル×4方向で最大140通り。一度処理した到着状態からは新しい光を積まないので、固定盤面の評価が循環し続けることはありません。重複したキュー要素を取り出すことはあるため、140はwhile文の実行回数の上限ではありません。

循環した枝を打ち切る。光路全体を打ち切らない。

配布コードには、下側を巡って分岐器へ戻る枝と、右の灯台へ届く枝が共存する盤面もあります。検査結果は訪問状態10、灯台は点灯。continue でその枝を飛ばし、キューが空になるまで進めています。

この上限が成り立つのは、計算中に鏡が動かず、光の強さや色によってマスの作用が変わらないからです。光量の減衰や色の混合を追加するなら、状態の定義から見直す必要があります。

操作を確定したときだけ計算し直す

ゲーム側では Session が現在の盤面と光路を持ちます。鏡を回す Turn、一手戻す Undo、やり直す Reset が成功したタイミングで再計算します。以下は配布コードに含まれる最初の盤面を使った例です。

var session = new Session(Board.FirstLight());
// 初期状態は未点灯。鏡は (2,2)。
session.Turn(Board.Cell(2, 2));
// session.Path.Complete == true
// session.Path.Edgesを描画側へ渡す。
session.Undo();
// 初期の経路と手数へ戻る。

全点灯した後は Turn を受け付けません。ゲームではクリア演出中の連打で盤面が変わるのを防いでいます。描画側は確定した経路をすぐ反映し、鏡の残像や点灯の輪を別に動かします。

Unityへ組み込む範囲

Board.cs と必要に応じて Levels.cs をAssets以下へ置き、入力からセルを選ぶ処理、セル座標から表示座標への変換、辺の描画を接続します。Program.cs はコンソール検査専用です。この手順部分は接続箇所の説明で、完成画面の配布ではありません。実際に作った光の描画と比較結果は、スプライトとメッシュの比較記事に分けています。

12面は、初期状態だけでなく全向きを調べた

本体は各面の鏡を2方向ずつ切り替え、合計518配置を検査しました。初期クリア0、解なし0。各面のクリア配置は1つずつで、最短手数は順に 1, 2, 3, 3, 3, 3, 4, 5, 4, 4, 5, 6 です。

最初の配置では第11面の経路に壁があり、解なしを検出しました。壁の配置を直してからUnityで再コンパイルし、検査をやり直しています。盤面を作れたことと、解けることは別でした。 今回の配布サンプルでも、公開版の全518配置を再確認しています。

ただし、全探索で分かるのは解の有無や最短手数です。通常面30〜90秒という目標、人間が感じる難しさや面白さは未確認です。解の探索自体は既存の箱押しの生成・検証記事と関心が重なるため、ここでは光路の状態管理に絞りました。

クリックだけで解ける指摘を受けて9面を作り直した

公開後、「全部クリックしていけばすぐクリアできる」と指摘を受けました。旧検査は全鏡を回し終えた状態だけを見ていて、途中のクリアを拾っていませんでした。さらに、光をつなぐ順に正すだけで進める面が多く、解が1つという条件では考える必要を作れていませんでした。

v1.1では導入3面を残し、4〜12面を変更しました。この版の9面は3基のうち2基が点灯した状態で始まります。その2基を守って少しずつ足すだけでは解けません。いったん消灯する経路も受け入れ、最後に3基を同時点灯させます。手数制限は追加していません。現在のv1.3では、4面は分岐の導入へ変更し、最終面も初期状態が異なります。

7/9 → 0/9

順押しで解けた面

旧版 → v1.1、導入3面を除く

9/9 → 0/9

消灯せず解けた面

点灯数を減らさない全状態探索

62.8% → 21.0%

ランダム40回の成功率

各面1,000試行、9面平均の概数

順押しは左右・上下・行優先・列優先の8通りを連続2周し、各クリック後に判定。ランダム操作は面ごとに固定seedを使い、1,000試行ずつ測りました。点灯数を減らさない経路の有無は、新版の全1,550配置を状態グラフにして調べています。人間のプレイ時間や面白さの測定ではありません。

日本語版v1.2の4面の開始状態。3基中2基の灯台が点灯し、下の1基が消えている
v1.2時点の4面。2基点灯から始まるが、点灯数を一度減らさなければ3基同時には光らない。現在のv1.3とは異なる配置。

旧版で解放した面は維持し、作り直した面の星は取り直す形にしました。旧セーブ原文も同じブラウザに別キーで残します。すでに全クリアしていれば、面選択から4面以降をすぐ試せます。

分岐器を置いただけでは、分岐の問題にならなかった

次に全12面の正解の光路を調べると、4面は分岐器の説明を出しているのに、正解では一つも通っていませんでした。 最終面では二つの分岐器を通りますが、両方を空きマスへ置き換えても同じ鏡の向きでクリアします。正解が一つであることと、仕掛けを理解する必要があることは別でした。

v1.3では4面を、分岐した二本の光をそれぞれの岸の灯台へ届ける配置に変更しました。交差は0マスに減らし、まず分岐先を読み取れる導入にしています。最終面は灯台の位置と初期の鏡を変え、二つの分岐器が各々必要になる総合面にしました。7面の案内も、実際の解き方である「交差しても曲がらない光」へ合わせています。

0 → 1

4面で必須の分岐器

正解から外すと全点灯しない

0 → 2

最終面で必須の分岐器

1個ずつ外して必要性を確認

1,550

新版の全配置

全12面の解・ヒントも再検査

検査では全向きから正解を列挙し、各正解の分岐器を1個ずつ空きマスへ置き換えます。それでクリアできたら、「その分岐が必要」という仕様を満たしていません。これは設計を調べる比較で、ゲーム内で分岐器を取り外せるわけではありません。

最短手数は4面が3手、最終面が5手のままです。 最終面のランダム40操作は、同じ条件の1,000試行で旧106回・新版107回成功しました。人間にとって難しくなったという結果ではありません。今回直したのは、説明した仕掛けが実際の解法に必要でなかった点です。

灯路v1.3の最終面。上下の二つの分岐器から光を送り、三基の灯台を点灯させた状態
v1.3の最終面。この正解から二つの分岐器をそれぞれ外すと、全点灯が崩れる。

v1.1・v1.2の記録は退避して残し、4面と最終面の星だけを取り直す形にしました。ほかの10面の星と、解放済みの面は引き継ぎます。

よくある質問

よくある質問

このZIPだけで灯路の画面を作れますか?

再現できるのは光路計算と検査です。完成画面、音、マウス入力は含みません。光の描画方式は別の比較記事で説明しています。

Unityを起動しなくても試せますか?

配布サンプルは.NET 10 SDKで動きます。Board.csとLevels.csはUnityEngineに依存しないゲーム本体のコードです。

鏡を自由な角度で回せますか?

この実装は5列7行の盤面と上下左右の4方向、2種類の鏡に限定しています。自由角度の反射や光量の減衰には対応していません。

光が循環したら全体の計算を止めますか?

同じマスと同じ入射方向の再訪だけを打ち切ります。キューに残った別の枝は続けるので、循環の外にある灯台も判定できます。

出典

出典

  1. 01.NET 10 SDKのダウンロードMicrosoft2026-09-21にSDK 10.0.401とWindows用SDKの配布を確認。サンプルの実行にはSDKを使う。

この記事は役に立ちましたか?

関連記事