[Astro #61] Three.js ぷよぷよ風パズルゲームの実装とReact key地獄

[Astro #61] Three.js ぷよぷよ風パズルゲームの実装とReact key地獄

1. 導入 〜電脳パズルとノイズの襲来〜

前回のシューティングゲーム(STG)の実装に続き、今回はターミナルから pzl コマンドを叩くことで起動する、3D仕様の「ぷよぷよ風対戦パズルゲーム」を構築しました。

このゲームは、VRMアバター(Lainやイレィナ)がフィールドの横に等身大で佇み、プレイヤーのゲーム展開をリアルタイムに見守ってくれる「Wired(電脳空間)」の文脈を意識した設計にしています。

基本となる集積回路風のソリッドなブロックを配置し、快適な操作性でヌルヌルと動き出したところまでは完璧でした。しかし、対戦ゲームとしての緊張感を高めるために、お邪魔ぷよに相当する「ノイズパケット(NOISE_PACKET)ブロック」をシステムへ配線した瞬間に、底暗いバグ地獄が幕を開けました。


2. 奇怪なバグの症状 〜空間に引き伸ばされるポリゴン〜

実装したノイズブロックは、周囲の通常パズルと合体しない独立した「漆黒のワイヤートーラス結晶」という、バグノードらしいアヴァンギャルドなデザインに仕上げました。

しかし、テストプレイ中に奇怪な現象が発生します。 連鎖を決めてお邪魔ノイズを消去した直後、その周囲や上空から落ちてきた通常色のブロックの形が、突如として三角形や、空間にびよーんと長く引き伸ばされた薄いスロープ状の板に変形し、そのまま画面にフリーズしてしまうのです。

不思議なことに、通常ブロックだけをいくら綺麗に連鎖消去しても、この形状の歪みは一切発生しません。 バックエンドの配列データが壊れたのかと思い、プログラム内のグリッドデータを console.table() で徹底的にスキャンしてみましたが、内部の多次元配列には一切の異常値がなく、データは完全に正常に整列していました。

「データは100%正しいのに、画面の3D描画だけが狂っている」という、極めて不気味な状況に陥ったのです。


3. 泥沼の検証と、崩れ去った誤った仮説たち

原因を特定するため、デバッグの過程でいくつかの仮説を立てては実験を繰り返しました。今振り返ると、完全に沼にハマっていた時間です。

仮説A:calculateMatrixBounds(結合吸着処理)の数学的バグ

同じ色のチップ同士が近接した際にマージンを埋めて綺麗にくっつけるため、サイズと中心オフセットをリアルタイムに伸縮計算する数式を組んでいました。「ノイズが消えた瞬間に、この計算式が空気の座標を誤認して破綻したのではないか」と疑いました。 → 結論:関係ありませんでした。

仮説B:非同期処理のタイミングのズレ

Reactのステート更新(setGrid)と、Three.js(React Three Fiber)の描画スレッドの間にミリ秒単位のラグがあり、空中分解した瞬間をレンダリングしてしまったのではないか。 → 対策:isAnimating フラグを新設して描画を強制ロックしようと試みましたが、非同期の隙間をすり抜けてしまい届きませんでした。

仮説C:処理中の結合ミュート漏れ

連鎖が解決して重力落下が起きている最中は、吸着の形状計算を完全にオフにするべきだと考えました。 → 対策:進行フラグ(isProcessing)をレンダラーへ配線してガードを敷きましたが、症状は黙りませんでした。

💡 突破口となった実験

ここで、私は「バグの原因をこれ以上付け足すのをやめよう」と考え、思い切って『同じ色同士が吸着してくっつく処理を、1行残さず完全に削除する』という実験を行いました。

グラフィックの面白みはなくなりますが、これで形が崩れなくなれば吸着ロジックが犯人だと確定するはずでした。しかし結果は、結合処理をすべて削り取ってシンプルな独立した四角箱に戻した状態でも、紫や青のブロックが相変わらず「▂」の形に潰れて固まったのです。

ここでようやく、「原因は数式のバグではなく、全く別の次元にある」と確信し、仮説の切り分けに成功しました。


4. 真の原因 〜React key props と 3Dジオメトリの再利用〜

結論から言うと、真犯人は React の仮想DOMにおける差分更新(Reconciliation)の仕組み と、Three.jsのジオメトリ保持 のすれ違いでした。

これまでのコードでは、盤面のグリッドを描画する際、以下のように座標ベースで key を指定していました。

// ❌ NG:座標だけの固定 key
{grid.map((row, y) => row.map((colorId, x) => (
  <group key={`${x}-${y}`}>
    <ChipMesh colorId={colorId} />
  </group>
)))}

2DのHTML要素を扱う一般的なReact開発であれば、これで何の問題も起きません。しかし、今回の対戦型ノイズシステムの中ではこれが致命的な罠になりました。

Reactは key が同じである限り、「同じコンポーネントがそこに存在し続け、プロップス(色のデータ)だけが更新された」と判断し、要素を新しく作らずに既存のコンポーネントを破棄せずそのまま再利用(使い回し)します。

通常ブロック(colorId=1〜5)の間では問題が起きにくかったのですが、ノイズブロック(colorId=6)が消去され、重力によって上の段から別の色のブロックがその座標へ一瞬で大ワープしてきた際、この使い回しが牙を剥きます。

データ上はノイズから通常ブロックへと瞬時に書き換わっているにもかかわらず、Reactがコンポーネントを再利用したため、Three.jsの内部でノイズ用に生成されていた torusRef(ドーナツ型のジオメトリ)の残像を持ったまま、新しい色のメッシュの座標計算と衝突してレンダリングされてしまっていたのです。2Dなら表面のテキストが変わるだけで済みますが、3D空間ではジオメトリという「目に見える物理的な物体」が残るため、形状が激しく歪むというド派手なエラーとなって表面化していました。

🛠️ 解決策

修正は驚くほどシンプルです。key の文字列の中に、座標だけでなく colorId(色の変化) も含めるだけです。

// ⭕️ OK:colorId を含めてコンポーネントの強制再生成を促す
{grid.map((row, y) => row.map((colorId, x) => (
  <group key={`${x}-${y}-${colorId}`}>
    <ChipMesh colorId={colorId} />
  </group>
)))}

このように key に色情報を含めることで、ノイズ(6)から通常色(1〜5)にデータが切り替わった瞬間、Reactは「これは完全に別のコンポーネントに置き換わった」と認識します。古いメッシュやジオメトリは綺麗にアンマウント(破棄)され、完全にまっさらな新しい3Dコンポーネントが生成されるため、怪現象ポリゴンは跡形もなく100%消滅しました。


5. おまけ 〜内周ノイズの消去漏れとBFSでの解決〜

今回のデバッグのために、ひたすらLain(CPU)とお邪魔ノイズを送り合ってテストプレイを繰り返していたところ、もう一つのロジックバグを発見しました。

お邪魔ノイズが四角く大量に固まって盤面に敷き詰められた際、なぜか「外周のノイズは通常ブロックの消滅に巻き込まれて消えるのに、中央に埋もれた内側のノイズパケットだけがデータとして消去されずに中空に取り残される」という問題です。

従来の「隣接4マスだけを一瞬チェックする」単純なスキャン方式(eraseConnectedBlocksAndNoise)では、通常色に直接触れていない「内周のノイズ」まで過電流が届いていませんでした。

これに対しては、消去が確定した通常ブロックの座標をスタート地点(根)とし、隣接するノイズブロックを BFS(幅優先探索) でキューに詰めながら芋づる式に奥までスキャンするロジックを注入しました。これにより、塊で降ってきたノイズパケットも、外殻がハッキング(デクリプト)された瞬間に、内側まで一斉に連鎖して綺麗に消し飛ぶようになり、パズルとしての戦略性と爽快感が劇的に向上しました。


6. 教訓 〜長い戦いを経て〜

今回の長いバグとの戦いから得た知見は、今後のフロントエンド3D開発において極めて大きな財産になりました。

  • Three.js + React(R3F)では、形状や種類、色が可変する要素の key に、必ずその状態の変化を含める(習慣にする)。
  • 「配列データは完全に正常なのに、3Dの見た目だけが狂う」時は、ReactのReconciliationによるコンポーネント・ジオメトリの不正な使い回しを真っ先に疑う。
  • 多次元グリッドのデバッグには、console.table(grid.slice().reverse()) が最高に見やすい(フィールドの上下の上下が反転せず、実際の画面と同じ向きでログが読めるため)。
  • 泥沼にハマった時こそ、「機能を極限まで引き算する実験(吸着の完全削除)」を行う。これによって選択肢が絞られ、仮説を一つずつ確実に潰すことができる。