[Astro #70] WebGLデータ駆動型ADVにおけるストラテジーパターンの統合と床抜けバグの修正

[Astro #70] WebGLデータ駆動型ADVにおけるストラテジーパターンの統合と床抜けバグの修正

本日の開発内容を技術記事の形式でまとめました。感情的な表現を排し、客観的な仕様変更とリファクタリングの内容を記載しています。

はじめに

本日は、開発中のWebGL(React Three Fiber / Three.js)ベースのADVゲームにおいて、空間演出の調整、敵の特殊攻撃システムの疎結合化(リファクタリング)、およびシーン遷移時における物理演算の不具合修正を実施しました。

1. 漆黒フォグによる空間演出とモノクロアバターの同化

背景と目的

3Dグラフィックスの進化において、高精細なディテールを遠景まで精密にレンダリングする手法が一般的ですが、本プロジェクトではあえて「情報を削ぎ落として見せない」という「引き算の演出」を主軸に置いています。世界の先をフォグや暗闇によって遮断することで、プレイヤーの想像力を喚起し、空間特有の緊張感と没入感を最大化することが目的です。これは、1999年の初代『サイレントヒル』がハードウェアの描画限界を濃霧と暗闇によって独自の恐怖演出へと昇華させた技術的アプローチの系譜を踏襲したものです。

データ駆動によるフォグインフラの構築

本システムでは、ステージ全体の空気感や視界の限界距離をソースコードにハードコーディングせず、すべて外部の環境定義データ(Stages.json)から動的にバインドするインフラを構築しています。

スキーム定義(Stages.json)

各ステージのオブジェクト内に、フォグの開始距離(near)および終了距離(far)を定義します。

{
  "id": "STAGE_CAVE",
  "name": "海底洞窟",
  "file": "/models/adv/house_in_the_cave.glb",
  "useMagicLight": true,
  "fog": {
    "color": "#000000",
    "near": 0.5,
    "far": 12.0
  }
}

動的制御ロジック(ADVManager.tsx)

ADVManager.tsx 内の useEffect ライフサイクルにおいて、現在のステージ設定(stageConfig.fog)を監視し、ルート空間(THREE.Scene)のフォグをリアルタイムに更新・破棄します。

// 🌫️ JSONのパラメータに基づいてルート空間の霧を動的に制御するプロトコル
useEffect(() => {
  if (!scene) return;

  if (stageConfig.fog) {
    // JSONにfog設定がある場合は、その値を使って霧を生成
    scene.fog = new THREE.Fog(
      stageConfig.fog.color || '#000000',
      stageConfig.fog.near ?? 0.5,
      stageConfig.fog.far ?? 12.0
    );
  } else {
    // 設定がないステージではフォグを完全に消去
    scene.fog = null;
  }

  return () => {
    scene.fog = null;
  };
}, [currentStageId, scene, stageConfig.fog]);

このインフラにより、イレイナちゃんが携帯する魔法の光(MAGIC_LIGHT)の届く減衰距離(12m)に対して、フォグの終了距離(far)を 12.016.0 へとJSON側から書き換えるだけで、光すら霧に呼び止められて届かない「一寸先も闇」の廃道状態から、ゲーム的な明暗バランスまでをコードの再コンパイルなしで即座に調整可能にしています。

396KBの超軽量モノクロアバターの配置と視覚ハック

本演出をさらに際立たせる要素として、teru64氏制作のイラスト調モノクロアバター(「ユイ」「シャチくん」)を海底洞窟の家(STAGE_CAVE)にNPCとして配置しました。

驚異的なアセット軽量化の要因

人型ボーンおよび表情モーフを内蔵したVRMモデルでありながら、ファイルサイズが 396KB という異次元の軽量性に収まっている理由は、以下のモデリング・最適化アプローチにあります。

  1. テクスチャバイナリの完全排除:モデルのデータ容量の大部分を占める画像テクスチャを使用せず、数ピクセル四方の最小限のカラーパレット、あるいは純粋なRGBのカラー数値(#FFFFFF#000000)のみでパーツを塗り分けています。
  2. 頂点データ(ポリゴン)のデトックス:イラストの美しい輪郭線を3Dでデフォルメ造形したミニマルなメッシュ構造により、頂点座標データが物理的に極限まで削られています。
  3. Unlitマテリアルへの最適化:複雑なPBR(物質の金属度や粗さ)の計算を必要とせず、色面をそのまま描画するシンプルなシェーダーで駆動します。

Webベースのフロントエンド(Three.js / React Three Fiber)において、アセットが軽量であることは初期ロード時間の削減、およびVRAM(グラフィックメモリ)消費の抑制に直結する最大の美徳(正義)です。

境界線の消失と「引き算の視覚演出」

この白黒フラットなアバターを漆黒フォグ(#000000)が充満する海底洞窟に設置した瞬間、3D空間上で強力な視覚ハックが発動します。

  • 黒マテリアルの完全同化:アバターの大部分を占める「黒髪」や「黒い衣服」のデータは、空間を支配しているフォグの黒とRGB値が完全に一致するため、3D空間上の境界線が消失し背景の闇へ100%シームレスに溶け込みます。
  • 白マテリアルの浮遊効果:一方で、「白い肌」「白いシャツ」「髪留めのロゴ」などの純白のパーツのみが、ライト(MAGIC_LIGHT)の照射範囲に触れた際、暗闇の中に切り抜かれたホログラム、あるいは幽霊のように鮮烈に浮かび上がります。
アバター配置状態と視覚変化
シャチくん木製階段の横のディープな暗黒に配置。黒髪が背景と同化し、白いシャツと顔の輪郭、スニーカーのみが虚空に浮かび上がります。
ユイドアフレームの奥の暗室手前に配置。衣服の黒が部屋の闇と同化することで、白く細い手足のライン、ジト目の顔、X型の白い髪留めだけがホログラムのようにクッキリと切り抜かれます。

手動でステージのマテリアルノードを極限までシンプルにクリーンアップし、ノイズを排して等幅に整えたインフラの上に、この396KBの「白い影」たちが佇むことで、カラーアセットである旅人のイレイナちゃんと並んだ際の異質感が何倍にも跳ね上がり、退廃的で美しい「本物の異界の聖域」がブラウザ上に具現化されました。

2. ストラテジーパターンを用いた敵特殊攻撃システムの統合

背景と課題

敵の特殊攻撃(例: APPLE_DROP)のような演出駆動型のゲームロジックは、演出時間や効果音(SE)、被弾タイミングがスキルごとに全く異なるため、愚直に実装するとメインの戦闘コンポーネント(ADVBattle.tsx)内に肥大化した条件分岐(if-elseswitch)を生み出す原因となります。これは密結合を招き、新しい敵やスキルを追加するたびにコアロジックを修正せねばならず、デバッグ時のリスクを高めます。

この課題を解決するため、オブジェクト指向デザインパターンの一つである「ストラテジーパターン(Strategy Pattern)」を導入し、スキルの実行ライフサイクルを戦闘コンポーネントから完全に分離・疎結合化しました。

ストラテジーレジストリによる疎結合化

リファクタリング後のシステムでは、各特殊攻撃のエフェクトクラスが共通のインターフェース(SpecialAttackPayload)を介して通信し、戦闘コンポーネント側は「レジストリ・マップ」から該当するインスタンスを動的にルックアップして処理を委譲(デリゲート)する構造をとっています。

🎯 ライフサイクル統合のための共通型定義

EnemyAppleDrop.ts 側に、バトル側の状態変化やターン遷移のトリガーを安全に引き受けるペイロード型を定義しています。

export interface SpecialAttackPayload {
  special: any;                     // JSONから渡されるスキル定義データ
  playerGroundPos: THREE.Vector3;   // プレイヤーの足元座標
  onDamage: (damage: number, hitSe?: string) => void; // 着弾(被弾)時の状態変化トリガー
  onComplete: () => void;           // 演出終了時のターン遷移トリガー
}

🎛️ 動的ルックアップレジストリ(ADVBattle.tsx)

戦闘コンポーネント側では、以下のようにキーとインスタンスをマッピングし、条件分岐を完全に消滅させています。

// ── 📊 敵の特殊攻撃を司るストラテジーレジストリ・マップ ──
const specialAttackRegistry = useMemo(() => {
  return {
    'APPLE_DROP': enemyAppleDropSystem, // 数式リンゴ雨システム
  };
}, [enemyAppleDropSystem]);

const executeEnemySpecialAttack = (special: any) => {
  setEnemyActiveAnim(special.animId || "ADV_ENEMY_ATTACKS");

  const playerGroundPos = new THREE.Vector3(battleConfig!.playerStartPosition[0], battleConfig!.playerStartPosition[1], battleConfig!.playerStartPosition[2]);
  playerGroundPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);

  // 🛠️ タイプに応じた固有のタイムラインクラスを動的ルックアップ
  const attackSystem = (specialAttackRegistry as any)[special.type];

  if (attackSystem) {
    // 🚀 カスタムスキルクラスへコンテキストを丸ごとデリゲート
    attackSystem.startAttack({
      special,
      playerGroundPos,
      onDamage: (damage: number, hitSe?: string) => {
        audioController.playSe(hitSe || 'SE_HIT_NORMAL', 0.6);
        setIsPlayerHurt(true);
        // クロージャの罠を防ぐため、Refから最新のHPを取得して安全にダメージ計算
        const nextPlayerHp = Math.max(0, workingPlayerHpRef.current - damage);
        setWorkingPlayerHp(nextPlayerHp);
        // ダメージポップアップの生成ロジック...
      },
      onComplete: () => {
        setEnemyActiveAnim("IDLE_BATTLE_02");
        if (workingPlayerHpRef.current > 0) {
          setBattlePhase('PLAYER_TURN');
        } else {
          handleBattleDefeat();
        }
      }
    });
  } else {
    // レジストリにない既存のPROJECTILE(直線弾)処理への安全なフォールバック
  }
};

これにより、効果音を鳴らすタイミング(詠唱開始時)や、最初の1個が着弾した瞬間のHP減算判定、演出終了後のターン移行といった固有タイムラインの全仕様が EnemyAppleDrop.ts 内にカプセル化され、ADVBattle.tsx のコード行数は将来的にスキルが増えても永久に増加しないアーキテクチャが確立されました。

0KBジオメトリによる数式リンゴの最適化

本プロジェクトのメモリおよびネットワーク転送負荷の最適化(デトックス)として、リンゴの3D表現には外部のGLBアセットを一切使用せず、Three.js標準の THREE.SphereGeometry の頂点データを数学的にモーフィング(変形)させる「0KBジオメトリ」を採用しています。

🍏 リンゴ化モーフィング数式プロトコル

球体の全頂点を走査し、ラジアン角(theta)と高さ(y)をベースに以下の3つの数理変形を掛け合わせて、有機的なリンゴの形状へと再レンダリングします。

  1. 上下のへこみ(ディンプル): 1.00.22e6.0(1.0y)1.0 - 0.22 \cdot e^{-6.0 \cdot (1.0 - |y|)} Y軸のトップとボトム(頭とお尻)の横半径を内側に押し込みます。
  2. 重心の調整(テーパリング): 1.0+0.12y1.0 + 0.12 \cdot y 上半分(y>0y > 0)をふっくらと膨らませ、下半分(y<0y < 0)に向かってシュッとすぼめる洋梨型の輪郭を作ります。
  3. 縦の凹凸(リブ効果): 1.0+0.04sin(θ5)1.0 + 0.04 \cdot \sin(\theta \cdot 5) 真上から見たときに、なだらかな5つの山(五切れの隆起)ができるように半径をわずかに波打たせます。
// THREE.SphereGeometryの頂点をハックしてリンゴの形状に変形
const geo = new THREE.SphereGeometry(1, 64, 64); // セグメント数を64へ引き上げハイポリ化
const posAttr = geo.attributes.position;
const v = new THREE.Vector3();

for (let i = 0; i < posAttr.count; i++) {
  v.fromBufferAttribute(posAttr, i);
  const theta = Math.atan2(v.x, v.z);
  const y = v.y; // -1.0 ~ 1.0

  const dent = 1.0 - 0.22 * Math.exp(-6.0 * (1.0 - Math.abs(y)));
  const taper = 1.0 + 0.12 * y;
  const rMod = 1.0 + 0.04 * Math.sin(theta * 5);

  v.x *= dent * taper * rMod;
  v.z *= dent * taper * rMod;
  v.y *= 0.88; // 全体的な上下の潰し加工

  posAttr.setXYZ(i, v.x, v.y, v.z);
}
geo.computeVertexNormals(); // 法線ベクトルの再計算

プロトタイプ段階では軽量化を意識しすぎてセグメント数を 24 まで削減したため、表面のポリゴンに顕著なカクつき(面のガタつき)が生じる「お粗末なビジュアル」になっていました。これを贅沢に 64, 64 の高解像度へ引き上げることで、データ容量は実質 0KB(数式定義のみ)に維持したまま、ライト(pointLight)に照らされた際につるんとした美しい光沢(法線グラデーション)を持つ、最高にハイクオリティな完熟リンゴへとリビルドすることに成功しました。

遭遇角度(encounterRotY)によるワールド座標系の補正

等速ポトン落下アニメーションの実装に伴い、3D空間における「ローカル座標」と「ワールド座標」の不一致による位置ズレ問題に直面しました。

⚠️ 発生した座標ズレのバグ

ADVBattle.tsx は、プレイヤーと敵が直面したエンカウント時の遭遇角度(props.encounterRotY)をコンポーネントのルート(<group rotation-y={props.encounterRotY}>)に適用することで、シーン全体の回転を制御しています。 しかし、EnemyAppleDrop.ts がリンゴ雨のメッシュを追加する空間は、この回転グループの外側にある this.scene.add(apple)(グローバルシーン空間) でした。

その結果、バトル側から渡されたプレイヤーの初期位置データ(ローカル座標)をそのまま落下目標点(targetX, targetZ)に指定すると、回転したバトルステージの見た目上の位置と、グローバル空間上の位置との間で「回転角の乗算漏れ」が発生し、リンゴが誰もいない虚空へ落下するバグが生じていました。

🛠️ 回転行列による補正ハック

この空間座標系の不一致を解消するため、ADVBattle.tsx 側からペイロードを渡す直前に、Three.js のベクトル回転数式 applyAxisAngle を噛ませてワールド座標へと強制変換するプロトコルを実装しました。

const playerGroundPos = new THREE.Vector3(
  battleConfig!.playerStartPosition[0],
  battleConfig!.playerStartPosition[1],
  battleConfig!.playerStartPosition[2]
);

// 🎯 リンゴ雨は scene に直接 add されるため、遭遇角度(encounterRotY)でY軸回転させてワールド座標に変換する
playerGroundPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), props.encounterRotY);

この補正により、どの角度・どの位置で敵とエンカウントが発生したとしても、内部で空間行列の整合性が完全に維持され、イレイナちゃんの足元のパステルピンク魔法陣と頭上からのリンゴ雨の落下位置がミリ単位で寸分違わず一致する、完璧な空間同期を実現しました。

3. バトル復帰時における床抜け(無限落下)バグの修正

背景と課題(不具合現象)

3Dゲーム開発、特にWebブラウザ上で物理演算や衝突判定(コライダー)を動的に生成するシステムにおいて、シーン遷移時は最もバグが発生しやすい臨界点となります。本プロジェクトにおいて、戦闘終了後にバトルシーン(BATTLE)から元の探索シーン(EXPLORE)へ復帰した際、特定の複雑なGLB地形メッシュ(例: house_in_the_cave.glb)において、プレイヤーアバターが地面をすり抜けて遥か下方の奈落へと無限落下する致命的な「床抜けバグ」が観測されました。

原因の特定(コライダーの初期化ラグとライフサイクルの罠)

この不具合の根本原因をシーングラフのライフサイクルおよびコンポーネントの初期化プロトコルから解析した結果、「1フレーム目の判定漏れ(ロードラグ)」と「親コンポーネントのState同期漏れ」の複合要因であることが判明しました。

  1. キャラクターコントローラーの再生成ラグ: バトルから復帰する際、コンポーネントの再初期化を確実にするために、key 属性の変更を伴ってキャラクター移動を制御する <ADVController> が一度完全に破棄され、新規に再マウント(再生成)されます。
  2. 衝突判定(Raycast)のバインド遅延: マウント直後の1フレーム目において、地形GLBの複雑なポリゴンメッシュに対する接地判定(Raycast)やコライダーのロード処理に微小なラグが発生します。物理演算(重力および自由落下計算)はマウント直後のフレームから即座に稼働するため、床の衝突判定が有効化される一瞬の隙(虚無のフレーム)を突いてアバターがコライダーの境界線を突き抜けてしまいます。一度床の下側へ突き抜けると、次フレームでコライダーが有効化された時点ではすでに「床より低い座標」に位置しているため、そのまま無限に落下し続けることになります。
  3. Manager側 State の同期タイミング: 従来の処理では、戦闘離脱時に spawnRef.current に復帰座標を保持しコントローラーへ渡していたものの、親コンポーネント(ADVManager.tsx)自体の位置State(avatarPos)が初動のフレームで完全に強制同期されていませんでした。この結果、1フレーム目にトランスフォームの位置ズレが誘発され、すり抜け現象を決定的なものにしていました。

着地マージンハックによる解決策(ライフサイクルガード)

このロードラグの隙を物理的に回避するため、ゲーム開発の王道アプローチである「復帰座標のY軸をわずかに浮かせ、初動フレームの位置Stateを絶対同期するハック」を実装しました。

🛠️ 修正された戦闘終了コールバック(ADVManager.tsx)

ADVManager.tsx 内の handleBattleEnd 内の離脱・勝利共通ルート(else ブロック)に、以下の位置強制上書きプロトコルを割り込ませました。

  // バトル終了時のコールバック
  const handleBattleEnd = (result: {
    outcome: 'VICTORY' | 'DEFEAT' | 'ESCAPE';
    newPlayerHp: number;
    newPlayerMp: number;
    newPlayerLevel: number;
    newPlayerExp: number;
  }) => {
    setIsBroomMode(false); // 魔法の箒OFF
    setPlayerHp(result.newPlayerHp);
    setPlayerMp(result.newPlayerMp);
    setPlayerLevel(result.newPlayerLevel);
    setPlayerExp(result.newPlayerExp);

    if (result.outcome === 'DEFEAT') {
      // 敗送時のリブート処理(マイルームへの送還ロジック)...

    } else {
      // 🎯 【床抜け・奈落落下を完全防御する着地マージンハック】
      // 複雑な洞窟GLBの衝突判定ラグを安全に超えるため、復帰座標のY軸に 0.25m のマージンを上乗せ
      spawnRef.current.y += 0.25;

      // 🧬 1フレーム目の描画位置を親のStateレベルから強制同期してトランスフォームのズレを完全防御
      setAvatarPos(spawnRef.current.clone());

      setAvatarRotY(encounterRotYRef.current);
      (window as any).nextSpawnRotationY = encounterRotYRef.current;

      // VRリグの安全なリセット処理
      if (camera.parent && camera.parent.type === 'Group') {
        camera.parent.position.set(0, 0, 0);
        camera.parent.rotation.set(0, 0, 0);
      }

      setBattleReturnTrigger(prev => prev + 1);
      setGameState('EXPLORE');
      setFadeState('fade-in');
      setTimeout(() => { setFadeState('none'); }, 600);
    }
  };

ハックによる堅牢なシーケンスの確立

この着地マージンインフラの導入により、バグの発生プロセスは以下のように安全なシーケンスへと上書きされました。

  1. 戦闘終了の暗転(フェードアウト)から探索画面へ戻った瞬間、イレイナちゃんは地面の正確な真上 0.25m(25cm)の上空座標 に配置されます。
  2. コライダーの構築にラグが生じている最初の1フレーム目を、空中という安全地帯のトランスフォームに固定することで、床コライダーをすり抜ける物理的接触自体を完全に回避します。
  3. 次のフレーム(あるいは数ミリ秒後)に床の衝突判定が完全に稼働した段階で、重力に引かれたアバターが上空から床に対して「ストンと綺麗に安全着地」します。

親コンポーネントの位置State(avatarPos)の初動強制バインドと、数理的な着地空間マージンの組み合わせにより、物理エンジンのロード負荷や処理遅延に依存しない、極めて堅牢なシーン遷移ライフサイクルガードが完成しました。

4. 総括

本日の実装により、WebGL空間におけるアート表現の強化のみならず、ストラテジーパターンによるクラス設計の標準化、ジオメトリの数理最適化、およびシーン遷移時における物理演算・非同期処理のバグ修正が完了し、システムの安定性が大幅に向上しました。