[Astro #57] 3D数式弾幕の本格移植とチャージ短縮アイテム実装における論理演算子の罠

[Astro #57] 3D数式弾幕の本格移植とチャージ短縮アイテム実装における論理演算子の罠

1. ボスの3D数式弾幕処理の修正と本格実装

実装の目的

今回の主な目的は、独立したテスト環境で構築していた「3D数式弾幕(螺旋、薔薇、パルサーなどの幾何学パターン)」のロジックを、本番環境のメインループ(STGManager.tsx)へ移植し、ステージボスの攻撃パターンとして本格的に組み込むことでした。

10秒ごとに自動で弾幕の数式パターン(DanmakuLibrary)を切り替え、プレイヤーに多彩な視覚的・ゲーム的な脅威を与える設計を目指しました。

発生した問題(躓いた点)

テスト環境では綺麗に描画されていた弾幕ですが、本番空間にそのまま配置したことで以下の4つの致命的な問題が発生しました。

  1. 進行方向の誤り(Z軸の反転) テストモジュール内での視点と本番環境の座標系の違いにより、弾が手前(プレイヤー側)ではなく奥(-Z方向)へ飛んでいってしまい、そもそも攻撃として成立しない状態になっていました。
  2. 3D空間特有の「密度の不足」 テスト段階での発射パラメータ(8WAY、0.4秒間隔)を広大な3D(VR)空間にそのまま適用した結果、弾と弾の隙間が広くなりすぎてしまい、STG特有の「弾の壁」という迫力が消え、「スカスカの雑な弾幕」に見えてしまう問題がありました。
  3. ボスの移動に伴う「軌道の崩れ」 本番環境のボスは Math.sin などを利用して左右に揺れるように巡回移動しています。静止状態を前提とした弾幕数式を移動しながら発射したため、移動の慣性によって弾の軌跡が引きずられ、綺麗な幾何学模様がただの不規則なノイズに崩壊していました。
  4. エイム(狙い)の欠如 弾幕が空間の固定方向へただ散らばるだけでプレイヤーの位置を全く参照していなかったため、プレイヤーから見ると「ただの背景の花火」になっており、避ける必要性や脅威が生まれていませんでした。

改善・修正内容

これらの問題を解消し、美しさとゲーム性を両立させるために、以下のチューニングとロジックの抜本的な書き換えを行いました。

密度の向上とZ軸の反転による空間適応

まず、広大な3D空間での視認性と迫力を確保するため、弾のオブジェクトプール(MAX_BOSS_BULLETS)を従来の40から80〜最大300へと大幅に拡張しました。 合わせて、同時発射数(WAYS)を16に倍増させ、発射間隔を0.15秒に短縮して「超高速連射」を実現しました。進行方向のZ軸速度も確実に手前(+Z)に向かうように固定しています。

プレイヤー方向へのエイムとパターンの合成(完全版ロジック)

ボスの移動による軌道の崩れと、エイム欠如を同時に解決するため、「プレイヤーを狙うベース角度」に対して「数式から得た拡散角度」をブレンドするハイブリッド方式に変更しました。

数式から得たX, Yの座標値をそのまま足すのではなく、 「数式が本来描こうとしている角度(patternAngle)」を算出し、それをプレイヤーへのエイム角度(targetAngle)に加算する ことで、ボスの位置が変動しても「プレイヤーを中心とした美しい幾何学図形」が維持されたまま突進してくる本格的な3D弾幕が完成しました。

【修正コードの要点 (STGManager.tsx)】

// ボス射撃ループ内 (shootInterval: 0.15s, WAYS: 16)

// 1. ボスからプレイヤーへの直線エイム軸の角度(ラジアン)を計算
// 胸の高さ(playerPos.y + 1.0)を基準にして正確に狙う
const targetAngle = Math.atan2(
  (playerPos.y + 1.0) - boss.current.position.y,
  playerPos.x - boss.current.position.x
);

for (let i = 0; i < WAYS; i++) {
  const b = bossBullets.current.find(bullet => !bullet.active);
  if (b) {
    b.position.copy(boss.current.position);

    // 2. 外部ライブラリ(DanmakuLibrary)から純粋な2D拡散値(x, y成分)を抽出
    const pattern = currentDanmaku(stageTime.current, i, WAYS);

    // 3. 【重要】プレイヤーへ向かう直進軸をベースに、数式の拡散角度をブレンドする
    // pattern.x と pattern.y の比率から、その弾の本来の「広がり角」を算出
    const patternAngle = Math.atan2(pattern.y, pattern.x);

    // エイム方向を中心に、数式の形(螺旋や薔薇)を3D空間の正面にマッピング
    const finalAngle = targetAngle + patternAngle;

    // 数式全体の「広がり幅(半径)」をコントロール
    const spreadRadius = 0.25;

    // 4. 新しい3D進行方向ベクトルを組み立て(Zは手前固定)
    const dirX = Math.cos(finalAngle) * spreadRadius;
    const dirY = Math.sin(finalAngle) * spreadRadius;
    const dirZ = 1.0;

    // 正規化して均一な弾速(BOSS_BULLET_SPEED)を適用
    const velocity = new THREE.Vector3(dirX, dirY, dirZ).normalize().multiplyScalar(BOSS_BULLET_SPEED);
    b.velocity.copy(velocity);

    b.active = true;
  }
}

2. チャージショット時間短縮アイテム(CHARGE_SHORTEN)の実装

実装の目的

後半の高難易度ステージに対する救済措置として、強力な攻撃である「チャージショット」の発射に要する時間を短縮できる新規アイテム「CHARGE_SHORTEN」を追加しました。

初期状態でのチャージ完了時間を「3.0秒」とやや長めに設定し、アイテムを1つ取得するごとに0.5秒ずつ短縮、最大まで強化すると「1.0秒」で瞬時にチャージが完了する仕様としました。これにより、敵が連続で出現する場面でもプレイヤーが対応しやすくなるゲームバランスを狙いました。

発生した問題(躓いた点)

アイテムを追加するだけで完了する想定でしたが、実際にテストすると「アイテムを取ってもチャージ時間が短縮されない」「エフェクトの成長速度と実際の発射タイミングが合わない」という複合的なバグが発生しました。原因を調査したところ、以下の4つの問題が絡み合っていることが判明しました。

  1. 見た目と判定の致命的なズレ エフェクト側(STGChargeSystem.ts)は「固定値(1.0秒)で最大サイズになる」ようにハードコーディングされていた反面、判定側は「3.0秒間押し続ける」ことを要求していました。その結果、エフェクトが完成してからも虚無の時間を待たされる状態になっていました。
  2. タイマーの暴走(リセット漏れ) チャージを解除する releaseMagic() の処理内に、内部の累積時間(chargeTime)を 0 にリセットする処理が抜けていました。これにより、裏側でタイマーが加算され続け、2発目以降のチャージエフェクトがトリガーを引いた瞬間に最大化してしまうバグが発生していました。
  3. 成長カーブによる視覚的な錯覚 エフェクトのスケール計算に progress * progress(2次曲線)を用いていたため、初期値の3.0秒で計算すると「最初の2秒間はサイズがほとんど変わらず、最後の1秒で突然爆発的に巨大化する」という状態になっていました。これにより、プレイヤーの体感として「いつまで経っても育たない(6秒以上待たされている)」という錯覚を生んでいました。
  4. 【根本原因】STGController.tsxの論理演算子の罠 アイテム取得で変数が書き換わっても時間が短縮されなかった最大の原因です。コントローラー側の発射判定式において、論理和(||)を用いていたため、アイテム取得後の数値が正しく評価されず、常にデフォルト値の 3.0 が強制的に適用されるバグが潜んでいました。

改善・修正内容

これらの不整合を解消するため、システム全体(アイテム設定、エフェクト、入力判定)の連携を完全に見直しました。

アイテムデータとロジックの追加

stg_item.json のドロッププールに CHARGE_SHORTEN(水色のコーン型アイテム)を定義しました。STGManager.tsx 内の当たり判定において、このアイテムを取得した際、グローバル変数 (window as any).wiredRequiredChargeTime を0.5秒減算(下限1.0秒)するロジックを実装しました。

STGChargeSystem.ts の完全同期と修正

エフェクトと判定のズレを無くし、視覚的なフィードバックを強化しました。

  • エフェクトの進行度を chargeTime / 1.0 から chargeTime / requiredTime(動的変数)に変更し、見た目とシステム判定を完全に同期。
  • 成長カーブを等速(直線的)に変更し、「待たされている錯覚」を払拭。
  • チャージ完了(progress >= 1.0)の瞬間に、光球のコアマテリアルを白く輝かせる処理を追加し、プレイヤーが「いつ撃てるか」を直感的に視認できるようにしました。
  • releaseMagic() メソッド内に this.chargeTime = 0; を追加し、タイマーの暴走を根絶しました。

STGController.tsx の発射判定の修正(論理演算子の修正)

論理和(||)を Nullish coalescing operator(??)に変更しました。これにより、変数が 0 などのFalsyな値であっても意図通りに評価され、アイテムによる短縮時間が正しく判定に適用されるようになりました。

【修正コードの要点 (STGController.tsx)】

// ❌ 修正前: アイテムで時間を減らしても、常に右辺の3.0が評価されてしまう論理演算のバグ
// } else if (chargeTime.current >= (window as any).wiredRequiredChargeTime || 3.0) {

// ⭕ 修正後: ?? を使用し、値が取得できた場合はその短縮された時間を正しく適用する
} else if (chargeTime.current >= ((window as any).wiredRequiredChargeTime ?? 3.0)) {
  window.dispatchEvent(new CustomEvent('wired-stg-charge-cancel'));
  window.dispatchEvent(new CustomEvent('wired-stg-charge-shoot', ...));
}

【修正コードの要点 (STGChargeSystem.ts)】

// エフェクトの進行度をグローバル変数(短縮された時間)と同期させる
const requiredTime = (window as any).wiredRequiredChargeTime || 3.0;
const progress = Math.min(this.chargeTime / requiredTime, 1.0);

// 錯覚を起こす2次曲線をやめ、直線的な成長カーブへ変更
const scale = progress * 0.8;
this.chargeGroup.scale.set(scale, scale, scale);

// 完了時の視覚的フィードバック
if (progress >= 1.0) {
  (orb.material as THREE.MeshBasicMaterial).color.setHex(0xffffff); // 完了時は白く発光
}

以上の修正により、各ファイルの入力判定・変数管理・エフェクト描画が1つの仕様のもとに統合され、ボス弾幕の正確な描画と合わせ、設計意図通りに機能するゲームサイクルが完成しました。