【Launch Condition】フェーズ 3.5

3/15/2026

AI Game Unity

2Dゲーム『Launch Condition』の作成つづき。

前回のフェーズ3ではひとまず地球からRocketを発射して軌道に乗せる様な動作が出来るようになった。実際に軌道に乗せるには完璧な初期条件の設定が必要であり、当初のコンセプト通りではあるが難易度は超絶高い。フェーズ4では複数天体・多段ロケット化を考えているが、まずは地球だけの状態である程度完成度を高める作業としてフェーズ"3.5"を実装。

フェーズ3.5実装内容

「ただ飛ばす」段階から「制御して軌道に乗せる」段階へと進化させる。基本的にAntigravity(Model Quota制約もあり、基本的にGemini Flashを使用)が作成し、動かしてみて課題を見つけていくスタイルで実行。問題発生時はなんだかんだコードを読み解くことも多々あるため、勉強にはなっているはず。。。

1. HUDの実装:情報の可視化

飛行状態を把握するため、UI Toolkit を使用した本格的な HUD(Head-Up Display)を導入した。デザインについても曖昧な指示で作り始めて、出力された結果をみながら何度も修正依頼をかけて直してもらったり、自分でコードを見に行って直したり。
画像は最終形態。作成中様々なパラメータを足しながらデザインを都度ブラッシュアップ。レイアウトが上手く揃わないことが多かったが、スクリーンショットをAIに読み取らせて修正&手作業でStyle Sheet(RocketHUD.uss)を修正
  • 高度表示: 地表からの距離 (km)。現在の軌道からリアルタイムに遠地点(Apogee)と近地点(Perigee)を算出。計算式は少々煩雑だが、軌道エネルギーと離心率から計算できる。
    • 軌道エネルギー: $\epsilon = \frac{v^2}{2} - \frac{\mu}{r}$
      • $\mu: 3.986 \times 10^{14}$ (GM, 地球標準重力定数)
      • $r: 6,371,000$ (地球中心からRocketまでの距離)
      • $v: (速度)$
      • $\epsilon \lt 0$ → 楕円軌道(周回)
      • $\epsilon \geq 0$ → 双曲線・放物線軌道(脱出)
    • 角運動量: $\quad h = |\vec{r} \times \vec{v}|$
    • 半長軸: $a = -\frac{\mu}{2\epsilon}$
    • 遠地点(Apogee): $\quad r_a = a(1+e)$
    • 近地点(Perigee): $\quad r_p = a(1-e)$
  • Gフォース表示: エンジン推力と重力から、ロケットにかかる加速度Gを以下の式で計算
    • $G = \frac{a_{real}}{g_0}$
      • $a_{real}$ は現実空間での加速度 ($m/s^2$)
      • $g_0$ は標準重力加速度 ($9.80665 m/s^2$)
  • 関連ファイル

RocketHUD.cs

using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// ロケットの飛行情報を画面に表示するHUDクラス。
/// UI Toolkit (UIDocument) を使用します。
/// </summary>
public class RocketHUD : MonoBehaviour
{
    public RocketTest rocket;
    public Transform earth;
    public UIDocument uiDoc;

    // 各情報を表示するラベル(UI Builderで名前を付けておく必要があります)
    private Label rocketNameLabel;
    private Label altitudeLabel;
    private Label totalVelocityLabel;
    private Label verticalVelocityLabel;
    private Label horizontalVelocityLabel;
    private Label gForceLabel;
    private Label fuelLabel;
    private Label timeScaleLabel;
    private Label pitchLabel;      // 新規
    private Label missionTimeLabel; // 新規
    private Label apogeeLabel;
    private Label perigeeLabel;
    private VisualElement fuelBarFill; // 燃料バーの本体

    void OnEnable()
    {
        var root = uiDoc.rootVisualElement;
        
        rocketNameLabel = root.Q<Label>("RocketNameVal");
        altitudeLabel = root.Q<Label>("AltitudeVal");
        totalVelocityLabel = root.Q<Label>("TotalVelVal");
        verticalVelocityLabel = root.Q<Label>("VerticalVelVal");
        horizontalVelocityLabel = root.Q<Label>("HorizontalVelVal");
        gForceLabel = root.Q<Label>("GForceVal");
        fuelLabel = root.Q<Label>("FuelVal");
        timeScaleLabel = root.Q<Label>("TimeScaleVal");
        pitchLabel = root.Q<Label>("PitchVal");
        missionTimeLabel = root.Q<Label>("MissionTimeVal");
        apogeeLabel = root.Q<Label>("ApogeeVal");
        perigeeLabel = root.Q<Label>("PerigeeVal");
        fuelBarFill = root.Q<VisualElement>("FuelBarFill");
    }

    void Update()
    {
        if (rocket == null || earth == null) return;

        UpdateHUD();
    }

    void UpdateHUD()
    {
        // --- 1. 高度の計算 (km) ---
        Vector2 toEarth = (Vector2)rocket.transform.position - (Vector2)earth.position;
        float distUnits = toEarth.magnitude;
        float surfaceRadiusUnits = PhysicsConstants.ToUnityDistance(PhysicsConstants.EarthRadius);
        float altitudeKm = (distUnits - surfaceRadiusUnits) * (PhysicsConstants.DistanceScale / 1000f);

        // --- 2. 速度の計算 (km/s) ---
        Vector2 velUnity = rocket.GetComponent<Rigidbody2D>().linearVelocity;
        Vector2 radialDir = toEarth.normalized;
        Vector2 tangentDir = new Vector2(-radialDir.y, radialDir.x);

        // 各成分 (Unity単位)
        float vVertUnity = Vector2.Dot(velUnity, radialDir);
        float vHorizUnity = Vector2.Dot(velUnity, tangentDir);

        // 現実単位 (m/s -> km/s)
        float vTotalKms = (velUnity.magnitude * (PhysicsConstants.DistanceScale / PhysicsConstants.TimeScale)) / 1000f;
        float vVertKms = (vVertUnity * (PhysicsConstants.DistanceScale / PhysicsConstants.TimeScale)) / 1000f;
        float vHorizKms = (vHorizUnity * (PhysicsConstants.DistanceScale / PhysicsConstants.TimeScale)) / 1000f;

        // --- 3. 燃料 (%) ---
        float fuelPercent = (rocket.RemainingFuelKg / rocket.fuelMassKg) * 100f;

        // --- 4. 姿勢と時間 ---
        float pitch = rocket.PitchAngle;
        int tMinutes = (int)(rocket.TotalTimeFromLaunch / 60);
        int tSeconds = (int)(rocket.TotalTimeFromLaunch % 60);

        // --- UIへの反映 ---
        if (rocketNameLabel != null) rocketNameLabel.text = rocket.RocketName;
        if (altitudeLabel != null) altitudeLabel.text = $"{altitudeKm:F1}";
        if (totalVelocityLabel != null) totalVelocityLabel.text = $"{vTotalKms:F2}";
        if (verticalVelocityLabel != null) verticalVelocityLabel.text = $"V:{vVertKms:F2}";
        if (horizontalVelocityLabel != null) horizontalVelocityLabel.text = $"H:{vHorizKms:F2}";
        if (gForceLabel != null) gForceLabel.text = $"{rocket.CurrentGForce:F2}";
        if (fuelLabel != null) fuelLabel.text = $"{fuelPercent:F0}%";
        if (fuelBarFill != null) fuelBarFill.style.width = Length.Percent(fuelPercent);
        if (timeScaleLabel != null) timeScaleLabel.text = $"x{PhysicsConstants.TimeScale:F1}";
        if (pitchLabel != null) pitchLabel.text = $"{pitch:F1} °";
        if (missionTimeLabel != null) missionTimeLabel.text = $"{tMinutes:D2}:{tSeconds:D2}";

        // --- 軌道要素 (Ap/Pe) ---
        if (apogeeLabel != null)
        {
            if (rocket.IsEscapeOrbit) apogeeLabel.text = "---";
            else apogeeLabel.text = $"{rocket.ApogeeRadius:F0}";
        }
        if (perigeeLabel != null)
        {
            perigeeLabel.text = $"{rocket.PerigeeRadius:F0}";
        }
    }
}

RocketHUD.uxml

<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
    <Style src="project:/Assets/UI/RocketHUD.uss" />
    <ui:VisualElement name="HUDContainer" style="flex-grow: 1; align-items: flex-start;">
        <ui:VisualElement name="LeftPanel" class="hud-panel">
            <!-- ロケット名表示 -->
            <ui:VisualElement name="RocketNameSection" class="rocket-id-panel">
                <ui:Label text="ROCKET ID" class="hud-label-small" />
                <ui:Label text="UNKNOWN" name="RocketNameVal" class="rocket-name-text" />
            </ui:VisualElement>

            <ui:Label text="MISSION STATUS" class="hud-header" />

            <!-- 高度・軌道解析 -->
            <ui:VisualElement class="stat-group">
                <ui:Label text="ALTITUDE" class="hud-label" />
                <ui:VisualElement class="value-row">
                    <ui:Label text="---" name="AltitudeVal" class="hud-value" />
                    <ui:Label text="km" class="hud-unit" />
                </ui:VisualElement>
                
                <ui:VisualElement class="sub-row">
                    <ui:VisualElement style="flex-grow: 1;">
                        <ui:Label text="AP (Radius)" class="hud-label-small" />
                        <ui:VisualElement class="value-row-tight">
                            <ui:Label text="---" name="ApogeeVal" class="hud-value-small" />
                            <ui:Label text="km" class="hud-unit-small" />
                        </ui:VisualElement>
                    </ui:VisualElement>
                    <ui:VisualElement style="flex-grow: 1;">
                        <ui:Label text="PE (Radius)" class="hud-label-small" />
                        <ui:VisualElement class="value-row-tight">
                            <ui:Label text="---" name="PerigeeVal" class="hud-value-small" />
                            <ui:Label text="km" class="hud-unit-small" />
                        </ui:VisualElement>
                    </ui:VisualElement>
                </ui:VisualElement>
            </ui:VisualElement>

            <!-- 速度 -->
            <ui:VisualElement class="stat-group">
                <ui:Label text="VELOCITY" class="hud-label" />
                <ui:VisualElement class="value-row">
                    <ui:Label text="---" name="TotalVelVal" class="hud-value" />
                    <ui:Label text="km/s" class="hud-unit" />
                </ui:VisualElement>
                <ui:VisualElement class="sub-row">
                    <ui:Label text="V:---" name="VerticalVelVal" class="sub-value" />
                    <ui:Label text="H:---" name="HorizontalVelVal" class="sub-value" />
                </ui:VisualElement>
            </ui:VisualElement>

            <!-- G-Force -->
            <ui:VisualElement class="stat-group">
                <ui:Label text="G-FORCE" class="hud-label" />
                <ui:VisualElement class="value-row">
                    <ui:Label text="1.00" name="GForceVal" class="hud-value" />
                    <ui:Label text="G" class="hud-unit" />
                </ui:VisualElement>
            </ui:VisualElement>

            <!-- 燃料 -->
            <ui:VisualElement class="fuel-container">
                <ui:VisualElement class="fuel-header-row">
                    <ui:Label text="FUEL" class="hud-label" />
                    <ui:Label text="100%" name="FuelVal" class="fuel-text" />
                </ui:VisualElement>
                <ui:VisualElement class="fuel-bar-bg">
                    <ui:VisualElement name="FuelBarFill" class="fuel-bar-fill" />
                </ui:VisualElement>
            </ui:VisualElement>

            <!-- 姿勢 (Pitch) と 経過時間 (T+) -->
            <ui:VisualElement class="stat-group-tight">
                <ui:VisualElement class="sub-row">
                    <ui:VisualElement style="flex-grow: 1;">
                        <ui:Label text="PITCH" class="hud-label" />
                        <ui:Label text="0.0 °" name="PitchVal" class="value-small" />
                    </ui:VisualElement>
                    <ui:VisualElement style="flex-grow: 1;">
                        <ui:Label text="TIME T+" class="hud-label" />
                        <ui:Label text="00:00" name="MissionTimeVal" class="value-small" />
                    </ui:VisualElement>
                </ui:VisualElement>
            </ui:VisualElement>

            <!-- 時間スケール -->
            <ui:VisualElement class="time-row">
                <ui:Label text="TIME WARP" class="hud-label" style="font-size: 8px; margin-bottom: 0;" />
                <ui:Label text="x1.0" name="TimeScaleVal" class="time-value" />
            </ui:VisualElement>

        </ui:VisualElement>
    </ui:VisualElement>
</ui:UXML>

RocketHUD.uss

/* =========================================
   RocketHUD.uss  -  ウルトラコンパクトデザイン
   ========================================= */

.hud-panel {
    background-color: rgba(5, 10, 20, 0.95);
    border-left-width: 3px;
    border-left-color: rgb(0, 255, 255);
    padding: 4px 8px; /* 上下左右のゆとりを最小化 */
    margin: 1px;
    width: 165px;
    border-radius: 4px;
    flex-direction: column;
}

/* ★超重要★ Labelのデフォルト余白を強制排除 */
Label {
    margin: 0;
    padding: 0;
}

/* ROCKET ID セクション */
.rocket-id-panel {
    margin-bottom: 0px;
    padding-bottom: 4px;
    border-bottom-width: 1px;
    border-bottom-color: rgba(0, 255, 255, 0.4);
    flex-shrink: 0;
}

.rocket-name-text {
    color: rgb(0, 255, 255);
    font-size: 14px;
    -unity-font-style: bold;
}

/* セクションヘッダー "MISSION STATUS" */
.hud-header {
    font-size: 10px;
    color: rgb(0, 255, 255);
    -unity-font-style: bold;
    border-bottom-width: 1px;
    border-bottom-color: rgba(0, 255, 255, 0.4);
    margin-bottom: 2px;
    padding-bottom: 1px;
    flex-shrink: 0;
}

/* 各データグループ (ALTITUDE, VELOCITY, G-FORCE) */
.stat-group {
    margin-bottom: 2px; 
    flex-shrink: 0;
    flex-direction: column;
}

/* 項目名ラベル */
.hud-label {
    color: rgba(160, 210, 255, 0.7);
    font-size: 9px;
    -unity-font-style: bold;
    margin-bottom: 1px;
}

/* 数値ラベル (大きめ, 白) */
.hud-value {
    color: rgb(255, 255, 255);
    font-size: 14px;
    -unity-font-style: bold;
}

/* 小さめの項目ラベル (AP/PEなど) */
.hud-label-small {
    color: rgba(160, 210, 255, 0.6);
    font-size: 8px;
    -unity-font-style: bold;
    margin-top: 2px;
}

/* 小さめの数値ラベル */
.hud-value-small {
    color: rgb(220, 240, 255);
    font-size: 10px;
    -unity-font-style: bold;
}

/* 数値と単位を並べる行 */
.value-row {
    flex-direction: row;
    align-items: flex-end;
}

.value-row-tight {
    flex-direction: row;
    align-items: flex-end;
}

/* 単位ラベル */
.hud-unit {
    color: rgba(200, 230, 255, 0.5);
    font-size: 8px;
    margin-left: 2px;
    margin-bottom: 1px;
}

.hud-unit-small {
    color: rgba(200, 230, 255, 0.4);
    font-size: 7px;
    margin-left: 2px;
}

/* V/H 速度やAP/PEを並べる行 */
.sub-row {
    flex-direction: row;
    justify-content: space-between;
    margin-top: 2px; 
    flex-shrink: 0;
}

/* V:xxx / H:xxx の小さい値 */
.sub-value {
    font-size: 10px;
    color: rgba(200, 230, 255, 0.8);
    -unity-font-style: bold;
}

/* ---- 燃料セクション ---- */
.fuel-container {
    border-top-width: 1px;
    border-top-color: rgba(0, 255, 255, 0.4);
    padding-top: 4px;
    margin-top: 2px;
    margin-bottom: 4px;
    flex-shrink: 0;
}

.fuel-header-row {
    flex-direction: row;
    justify-content: space-between;
    align-items: flex-end;
    margin-bottom: 2px;
}

.fuel-text {
    color: rgb(255, 180, 0);
    font-size: 10px;
    -unity-font-style: bold;
}

.fuel-bar-bg {
    background-color: rgba(255, 255, 255, 0.2); 
    border-width: 1px;
    border-color: rgba(0, 255, 255, 0.4);
    height: 6px; 
    min-height: 6px;
    border-radius: 3px;
    overflow: hidden;
    width: 100%;
}

.fuel-bar-fill {
    background-color: rgb(255, 180, 0);
    height: 100%;
}

/* ---- 姿勢と経過時間用 ---- */
.stat-group-tight {
    margin-bottom: 4px;
    border-top-width: 1px;
    border-top-color: rgba(0, 255, 255, 0.4);
    padding-top: 4px;
    flex-shrink: 0;
}

.value-small {
    color: rgb(255, 255, 255);
    font-size: 11px;
    -unity-font-style: bold;
}

/* ---- 時間スケール (最下部) ---- */
.time-row {
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    border-top-width: 1px;
    border-top-color: rgba(0, 255, 255, 0.4);
    padding-top: 4px;
    flex-shrink: 0;
}

.time-value {
    color: rgb(0, 255, 255);
    font-size: 11px;
    -unity-font-style: bold;
}
AntigravityがUnity UI Builder用の.uxmlと.ussを作成してくれるため、UI Builderではほぼ作業いらず。.uxmlの内容が読めてくると、UI Builderで修正するよりも直感的なときもある。

2. 操作性と視認性の向上

元々地球を中心においただけの画面だったため、Rocketが地球に対して小さすぎて見えない、もしくは見えるような倍率ではRocketが実際よりはるかに巨大になってしまう問題があった。また、シミュレーション時間が1倍では所要時間がかかりすぎ、倍速にしすぎると打ち上げ直後の姿勢変化が観測できないという課題もあった。
  • タイムワープ機能:キーボード操作(, / . キー)で、時間の進みを x1 から x100 まで動的に変更可能
  • 動的スケーリング: カメラを引いて宇宙全体を眺めてもロケットを見失わないよう、ズーム倍率に応じてロケットの表示サイズを自動で拡大する仕組みを導入

SpaceCamera.cs

using UnityEngine;
using UnityEngine.InputSystem; // 新しいInput Systemを使用

/// <summary>
/// ロケットを追跡し、マウスホイールでズームイン・アウトができるカメラスクリプト。
/// </summary>
public class SpaceCamera : MonoBehaviour
{
    [Header("Tracking")]
    public Transform rocket;        // 追跡対象(ロケット)
    public Transform planet;        // 追跡対象(地球など)
    public float smoothSpeed = 0.125f; // 追跡の滑らかさ
    public Vector3 offset = new Vector3(0, 0, -10f);

    [Header("Zoom Settings")]
    public float zoomSpeed = 5f;
    public float minSize = 100f;     // 5 -> 500 (1kmスケール用)
    public float maxSize = 250000f;  // 2500 -> 250000

    private Camera cam;
    private Transform currentTarget;

    // ターゲットごとの個別ズーム保持
    private float rocketZoom;
    private float planetZoom;

    void Start()
    {
        cam = GetComponent<Camera>();
        
        // 個別ズームの初期化
        rocketZoom = 1500f;   // 20 -> 2000
        planetZoom = 10000f;  // 200 -> 20000

        // 初期状態はロケットを注視
        currentTarget = rocket;
        cam.orthographicSize = rocketZoom;
    }

    void LateUpdate()
    {
        HandleTargetSwitch();

        if (currentTarget == null) return;

        // ターゲットの追跡
        Vector3 desiredPosition = currentTarget.position + offset;
        transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed);

        HandleZoom();
    }

    void HandleTargetSwitch()
    {
        if (Keyboard.current == null) return;

        // 現在のターゲットのズーム値を保存
        if (currentTarget == rocket) rocketZoom = cam.orthographicSize;
        else if (currentTarget == planet) planetZoom = cam.orthographicSize;

        // 1キーでロケット、2キーで惑星に切り替え
        if (Keyboard.current.digit1Key.wasPressedThisFrame)
        {
            currentTarget = rocket;
            cam.orthographicSize = rocketZoom;
        }
        else if (Keyboard.current.digit2Key.wasPressedThisFrame)
        {
            currentTarget = planet;
            cam.orthographicSize = planetZoom;
        }
    }

    void HandleZoom()
    {
        if (Mouse.current == null) return;

        float scroll = Mouse.current.scroll.ReadValue().y;
        if (Mathf.Abs(scroll) > 0.01f)
        {
            float targetSize = cam.orthographicSize - scroll * zoomSpeed * (cam.orthographicSize * 0.01f);
            cam.orthographicSize = Mathf.Clamp(targetSize, minSize, maxSize);
            
            // 保存用変数も更新
            if (currentTarget == rocket) rocketZoom = cam.orthographicSize;
            else if (currentTarget == planet) planetZoom = cam.orthographicSize;
        }
    }
}
フェーズ3.0で作成していたSpaceCamera.csに、視点ターゲットごとのズーム設定やターゲットの追跡機能を追加。

3. 打ち上げシーケンス:グラビティ・ターン

現実のロケットと同じ様な手順で軌道投入を行うように、「グラビティ・ターン(重力ターン)」を実装した。打ち上げ時は地面と垂直に打ち上がり、設定した高度(Pitch Over Altitude)にてわずかに姿勢を傾ける(Pitch Over Angle)。その後は、進行方向を向くように姿勢を調整し続けることで、地球の重力を利用してなめらかに円周方向へ加速していく。

Gravity Turn概要(Gemini作成)

4. エンジンモデル:比推力の高度依存

エンジンの性能が大気圧によって変化する、よりリアルなパラメータとして比推力(Specific Impulse)を取り入れた。
  • 海抜(Sea Level)と真空(Vacuum)での性能差を考慮
  • 計算式: 高度 $h$ における比推力 $I_{sp}(h)$ は、スケールハイト $H = 8,500m$ の簡略大気モデルを用いて算出:
$$I_{sp}(h) = I_{sp,vac} - (I_{sp,vac} - I_{sp,sl}) \cdot e^{-\frac{h}{8500}}$$

5. ロケットプリセット:実在機の性能を再現

世界中の代表的なロケットのスペックをプリセットとして設定。数値はAntigravityがWeb検索(主にWikipediaっぽい)により情報収集してきた値。ゲームなのでなんの問題もないが、正確な値を求めている方はご自身で調査をお願いします。

Rocket名 質量 [kg] 燃料質力 [kg] 比推力 [SL/Vac] 海抜推力 [N] 燃焼時間 [s]
Falcon 9 22,200 411,000 283 / 312 7,607,000 162
H3 35,000 225,000 300 / 426 4,400,000 292
Ariane 5 14,700 170,000 310 / 432 1,390,000 650
Soyuz 6,545 95,455 253 / 316 992,000 285
Epsilon 15,000 75,000 284 / 295 2,271,000 112

6. 精密な軌道予測:Euler vs. Runge=Kutta 4

ロケット打ち上げゲームなのでどこに飛んでいくのかは見えるようにしたいところ。軌道予測の線を当初Euler法で描画していたが、定期的に予測がリセットされる様に予測線が動き続ける状態だった。試しにもう少し本格的なシミュレーションっぽくRunge=Kutta法も使えるようにしてみた。

  • 前進オイラー法 (Euler Method)
    • 最もシンプルな手法で、現在の速度から次の位置を直線的に予測
    • 計算負荷は非常に低いが、時間の経過とともに誤差が蓄積
    • $y_{n+1} = y_n + f(t_n, y_n) \Delta t$
  • 4次ルンゲ=クッタ法 (RK4)
    • 1ステップの間に4つの地点で傾き(加速度・速度)をサンプリングし、それらを重み付け平均して次の位置を決める高度な手法
    • $y_{n+1} = y_n + \frac{\Delta t}{6}(k_1 + 2k_2 + 2k_3 + k_4)$
      • $\begin{aligned} k_1 &= f(t_n, y_n) \\ k_2 &= f(t_n + \frac{\Delta t}{2}, y_n + \frac{\Delta t}{2}k_1) \\ k_3 &= f(t_n + \frac{\Delta t}{2}, y_n + \frac{\Delta t}{2}k_2) \\ k_4 &= f(t_n + \Delta t, y_n + \Delta tk_3)\end{aligned}$
    • この手法により、タイムワープ(大きな $\Delta t$)を使用してもいい感じに計算してくれるはずだったが結果はあまり変わらず。原因を探る必要あり。
  • 推力のオン/オフ
    • エンジン噴射を続けた場合の予測と、今エンジンを切った場合の純粋な慣性飛行(弾道)予測を切り替え可能。
    • 燃焼終了時間など細かい設定は予測に含めていないため、慣性飛行になった状態の線のほうが有用

OrbitPredictor.cs

using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// 現在の速度と位置から、将来の飛行経路を計算して描画するクラス。
/// PhysicsConstants のスケーリングと万有引力の法則に基づきます。
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class OrbitPredictor : MonoBehaviour
{
    [Header("References")]
    public RocketTest rocket;
    public Transform earth;
    public Camera mainCamera;

    public enum PredictionMethod { Euler, RK4 }
    [Header("Prediction Mode")]
    public PredictionMethod method = PredictionMethod.Euler;
    
    [Header("Settings")]
    public int stepCount = 2500;       // 予測するステップ数 (延長)
    public float timeStep = 5f;      // 1ステップあたりのUnity時間(秒)- 分解能を調整
    public float minPredictionDistance = 0.1f; // 地面にぶつかったら計算を止める距離
    public float lineWidthPercent = 5.0f; // 画面サイズに対する線の太さの割合 (ユーザー推奨値5-10)
    public bool predictThrust = false;    // 予測に推力を含めるか(falseで安定した弾道軌道を表示)

    private LineRenderer lineRenderer;
    private Rigidbody2D rbRocket;

    void Awake()
    {
        lineRenderer = GetComponent<LineRenderer>();
        // 線の見た目設定
        lineRenderer.useWorldSpace = true;
        lineRenderer.alignment = LineAlignment.View; // カメラの方向を向くようにして太さを安定させる
        lineRenderer.startWidth = 1f; // 倍率(widthMultiplier)で調整するので一旦1にする
        lineRenderer.endWidth = 1f;
        lineRenderer.positionCount = 0;
    }

    void Start()
    {
        if (rocket != null)
        {
            rbRocket = rocket.GetComponent<Rigidbody2D>();
        }
        if (mainCamera == null)
        {
            mainCamera = Camera.main;
        }
    }

    void LateUpdate()
    {
        if (rocket == null || earth == null || rbRocket == null) return;

        UpdatePrediction();
    }

    void UpdatePrediction()
    {
        // --- 線の太さをカメラのズームに合わせる ---
        if (mainCamera != null)
        {
            // 画面に対して一定の幅(lineWidthPercent)に見えるように調整
            lineRenderer.widthMultiplier = mainCamera.orthographicSize * lineWidthPercent;
        }

        List<Vector3> points = new List<Vector3>();
        
        // --- シミュレーションの初期状態の設定 ---
        Vector2 simPos;
        Vector2 simVel;
        float simRemainingFuel;
        float simBurnTimer;
        Vector2 simForward; // ロケットの向き(推力方向)

        if (!rocket.IsLaunched) 
        {
            // 【打ち上げ前】現在の位置から、設定された推力でスタートすると仮定
            simPos = rbRocket.position;
            simVel = Vector2.zero;
            simRemainingFuel = rocket.fuelMassKg;
            simBurnTimer = 0f;
            simForward = rocket.GetLaunchDirection();
        }
        else
        {
            // 【飛行中】現在の物理状態をコピー
            simPos = rbRocket.position;
            simVel = rbRocket.linearVelocity;
            simRemainingFuel = rocket.RemainingFuelKg;
            simBurnTimer = rocket.BurnTimerS;
            simForward = rocket.transform.up;
        }

        float muUnity = PhysicsConstants.GetUnityMu(rocket.earthMassReal);
        float surfaceRadiusUnits = PhysicsConstants.ToUnityDistance(PhysicsConstants.EarthRadius);
        
        // 【チラつき防止】固定の時間ステップ
        float dtReal = 5.0f; 
        float dtForUnity = dtReal / PhysicsConstants.TimeScale;

        points.Add(simPos);

        float totalAngleDegrees = 0f;
        Vector2 lastRelPos = simPos - (Vector2)earth.position;
        Quaternion simRotation = rocket.IsLaunched ? rocket.transform.rotation : rocket.GetLaunchRotation();

        int maxPredictSteps = 5000;

        for (int i = 0; i < maxPredictSteps; i++)
        {
            // --- 1. 現状の加速度と姿勢の決定 ---
            Vector2 diff = (Vector2)earth.position - simPos;
            float dist = diff.magnitude;
            if (dist <= surfaceRadiusUnits) break;

            float simTotalElapsed = (rocket.IsLaunched ? rocket.TotalTimeFromLaunch : 0) + i * dtReal;
            float currentAltitude = (dist - surfaceRadiusUnits) * PhysicsConstants.DistanceScale;

            // 推力と燃焼判定
            bool isSimBurning = false;
            if (predictThrust)
            {
                if (rocket.useManualControl) isSimBurning = rocket.IsBurning && simRemainingFuel > 0;
                else
                {
                    if (simTotalElapsed < rocket.firstBurnDuration) isSimBurning = true;
                    else if (simTotalElapsed < rocket.firstBurnDuration + rocket.coastingDuration) isSimBurning = false;
                    else if (simTotalElapsed < rocket.firstBurnDuration + rocket.coastingDuration + rocket.secondBurnDuration) isSimBurning = simRemainingFuel > 0;
                }
            }

            // 姿勢のシミュレーション (RocketTest.cs の Slerp を再現)
            Vector2 targetUp = simRotation * Vector2.up;
            if (!rocket.useManualControl)
            {
                Vector2 goalDir;
                if (currentAltitude < rocket.pitchOverAltitude) goalDir = simPos.normalized;
                else if (currentAltitude < rocket.pitchOverAltitude + 2000f) goalDir = (Vector2)(Quaternion.AngleAxis(-rocket.pitchOverAngle, Vector3.forward) * simPos.normalized);
                else goalDir = simVel.sqrMagnitude > 1e-6f ? simVel.normalized : simPos.normalized;

                Quaternion targetRot = Quaternion.LookRotation(Vector3.forward, goalDir);
                simRotation = Quaternion.Slerp(simRotation, targetRot, dtReal * 2f);
                targetUp = simRotation * Vector2.up;
            }

            // 大気圧とIspの計算 (RocketTest.cs のロジックと同期)
            float atmP = Mathf.Exp(-Mathf.Max(0, currentAltitude) / 8500f);
            float simIsp = rocket.ispVacuum - (rocket.ispVacuum - rocket.ispSeaLevel) * atmP;
            float simBurnRate = rocket.thrustPowerN / (9.80665f * simIsp);

            if (method == PredictionMethod.RK4)
            {
                // RK4積分による位置と速度の更新
                Vector2 k1_v = GetAccel(simPos, targetUp, isSimBurning, simRemainingFuel) * dtForUnity;
                Vector2 k1_p = simVel * dtForUnity;

                Vector2 k2_v = GetAccel(simPos + k1_p * 0.5f, targetUp, isSimBurning, simRemainingFuel) * dtForUnity;
                Vector2 k2_p = (simVel + k1_v * 0.5f) * dtForUnity;

                Vector2 k3_v = GetAccel(simPos + k2_p * 0.5f, targetUp, isSimBurning, simRemainingFuel) * dtForUnity;
                Vector2 k3_p = (simVel + k2_v * 0.5f) * dtForUnity;

                Vector2 k4_v = GetAccel(simPos + k3_p, targetUp, isSimBurning, simRemainingFuel) * dtForUnity;
                Vector2 k4_p = (simVel + k3_v) * dtForUnity;

                simVel += (k1_v + 2f * k2_v + 2f * k3_v + k4_v) / 6f;
                simPos += (k1_p + 2f * k2_p + 2f * k3_p + k4_p) / 6f;
            }
            else
            {
                // オイラー法 (Semi-implicit Euler)
                Vector2 accel = GetAccel(simPos, targetUp, isSimBurning, simRemainingFuel);
                simVel += accel * dtForUnity;
                simPos += simVel * dtForUnity;
            }

            if (isSimBurning) simRemainingFuel -= simBurnRate * dtReal;
            
            points.Add(simPos);

            // 一周判定
            Vector2 relPos = simPos - (Vector2)earth.position;
            totalAngleDegrees += Vector2.Angle(lastRelPos, relPos);
            lastRelPos = relPos;

            if (totalAngleDegrees >= 360f) break;
            if (dist > 20000000f) break;
        }

        lineRenderer.positionCount = points.Count;
        lineRenderer.SetPositions(points.ToArray());
    }

    private Vector2 GetAccel(Vector2 pos, Vector2 forward, bool burning, float fuel)
    {
        Vector2 diff = (Vector2)earth.position - pos;
        float distSq = diff.sqrMagnitude;
        float muUnity = PhysicsConstants.GetUnityMu(rocket.earthMassReal);
        Vector2 gravityAccel = (diff / Mathf.Sqrt(distSq)) * (muUnity / distSq);

        if (predictThrust && burning && fuel > 0)
        {
            float thrustUnity = PhysicsConstants.ToUnityForce(rocket.thrustPowerN);
            float massUnity = PhysicsConstants.ToUnityMass(rocket.dryMassKg + fuel);
            return gravityAccel + forward * (thrustUnity / massUnity);
        }
        return gravityAccel;
    }
}
当初は予測線の太さを手動設定していたが、スケールが変わった際に見えなくなるなど色々問題が発生したため、ズームに合わせて太さ調整が出来るようにしている。
線色もLine Rendererで設定できる
結果的に精度が変わったのか明確ではないが、現状ではInspector内でEulerとRK4の切り替えが可能。将来的にはGame Setting画面を作成してそこで触れるようにはしたい。

フェーズ3.5の成果

今回は様々な機能を実装してきたが、前回以上にバイブコーディングになってきているため、実際に時間を要しているのは自分が理解するための時間が多い。理解しないとその次の修正プロンプトを出せないので仕方がないのだが、最初から作りたいもののイメージが固まっていれば出力された結果の評価も含めてAIにおまかせにして、もっと短時間で出来るはず。
Gameとしてはまだまだだが、シミュレータっぽくなってきた。初期設定だけで軌道に乗せるのはやはり高難易度。まだ完全な周回軌道には載せられていない。

Launch Condition開発フェーズ

これまでの経緯と今後の計画について、進捗状況をAntigravityがある程度まとめた内容を整理してみた。フェーズの定義について最初に明確にしきれていなかったことと、やはり途中で機能追加を繰り返していることも合って、ChatGPTアシスタントのみのときと比べて変化してきている。

Phase 1.0: ロケット発射 [投稿]

    目的: まずは作り始める。「初速と角度から放物線を描く」
  • 初速を規定し一定重力場での放物線運動を実装

Phase 2.0: 発射条件を“触れる”ようにする [投稿]

    目的: 発射条件を設定できるようにする。
  • 初速・射角のパブリック変数化し、UnityのInspectorで設定
  • RESETボタンによる環境の完全復元(燃料、タイマー、質量の同期)
  • UIでRESETボタンを追加

Phase 2.5: Spriteを画像へ変更 & 推力の設定 [投稿]

    目的:    ビジュアル化
  • 無機質な多角形だけだったPhase 2.0から、画像(ChatGPT生成)へ差し替え
  • 初速を与える代わりに、推力と噴射時間により加速させる計算の実装

Phase 3.0: 地球周回 [投稿]

    目的: 地球周回軌道への投入と視認性向上
  • 地球重力に基づく周回軌道の実装
  • 軌道予測(Orbit Predictor)の実装
  •  SpaceCameraによるカメラ切り替えと自動追従

Phase 3.5: 飛行制御の高度化

    目的:    インタラクティブな軌道操作と視認性の向上

  • HUD実装: 高度、速度(垂直/水平)、Gフォース、軌道要素(Ap/Pe)の可視化
  • 操作性向上: キーボードによる動的な時間倍率(x1〜x100)調節、ズームに応じたロケットのスケーリング
  • 発射後シーケンス: Pitch OverとGravity Turn
  • エンジンモデル: 比推力(Isp)の大気圧変化モデル導入
  • ロケットプリセット: Falcon 9, H3, Ariane 5 などの性能データ実装
  • 軌道予測ラインを修正(Eulerと4次Runge=Kutta法(RK4)を切り替え)

フェーズ 4: 他天体の追加と操作性の洗練

    目的    重力圏の拡張とより複雑な操作の実現

  • 月・火星など他の天体の追加と重力圏(SOI)の切り替え
  • 多段ロケット(Staging): 燃料切れによる機体切り離しと質量減少のシミュレーション
  • UI/操作性の更なる向上(UIでのパラメータ調整機能、マニューバ計画)

フェーズ 5: ゲーム化と仕上げ

    目的:    ゲームリリース
  • ステージ制(ミッション制)の導入
  • エフェクト(噴射・大気圏突入等)のブラッシュアップ
  • サウンド・BGMの追加

次回はフェーズ4で、他の天体追加&ロケット多段化でさらに宇宙シミュレーション感が増すはず。

※念のため注意

本投稿を含めこのBlog内に記載されているコードは自分用の備忘録として公開しています。動作を保証するものではありませんのであらかじめご了承ください。

人気の投稿

ラベル

Outdoor (23) 3D Printer (13) Raspberry Pi (11) Learning (10) Game (8) Ubuntu (8) Movie (7) Blog (6) Pico (6) FreeCAD (5) Unity (5) AI (4) MSFS (4) Python (4) Gadget (1)

QooQ