前回までChatGPTのアドバイスを元に少しずつ進めていたが、昨日Antigravityを入れてみて今後の作り方は劇的に変わりそうと感じている。これまでは困ったらChatGPTにアドバイスを求めるという使い方だったが、今後は作りたい内容と進め方の方針をAntigravityへ伝えればコード生成を進めてくれる。
あくまで自分自身でコードを理解したいという指示にしているため、バイブコーディング(※覚えたての単語はとりあえず使ってみる)でどんどん進めていくのと比べれば折角のAIパワーを活かしきれていないのだろうとも思っている。趣味なので開発スピードは問題にならないことと、やはりAIは時々嘘をつくので注意したい。
前回からの進捗(フェーズ2.5)
| 地球を突き抜けているが見た目はだいぶ進化 |
Spriteの画像化
フェーズ2までの味気ない雰囲気から、まずは形からゲームっぽくするため、Spriteを図形から画像へ差し替えた。ひとまずChatGPTに画像生成させてみる。既視感のある雰囲気がAIっぽい。
Unityが自動的に読み込んでくれて、画像と同じファイル名で.metaというファイルが作成されていた。
Projectフォルダに保管すると、Unityが勝手にProjectへ読み込んでくれていた。
UnityのObjectのInspectorでSprite RendererのところでSpriteに上記画像を設定
縮尺をどの様にデフォルメすべきかは、今後推力と重力の計算等を考える際には決める必要がある。デフォルトでは1Unity Unit = 1mのようだが、Main CameraのSizeが5の時は縦10 unitsのため10mのみ表示されている状態。
推力の設定
以前の初速指定から推力&噴射時間の設定に進化。計算上はそれっぽくしたものの、スケーリングをなんとかしない限り挙動はそれっぽくならない。
[Header("Launch Condition")]
[Range(-90f, 90f)]
public float launchAngle = 60f;
// Rocket Parameters
public float dryMass = 10000f;
public float fuelMass = 90000f;
private float remainingfuelMass;
public float burnRate = 100f;
public float thrustPower = 200000f;
public float burnDuration = 10f;
void ApplyThrust()
{
if (!isBurning) return;
float dt = Time.fixedDeltaTime;
// Burning Fuel
float usedFuel = burnRate * dt;
remainingfuelMass = Mathf.Max(0f, fuelMass - usedFuel);
// Update Mass
rbRocket.mass = dryMass + remainingfuelMass;
// Thrust Direction
Vector2 thrustDir = transform.up;
rbRocket.AddForce(thrustDir * thrustPower);
burnTimer += dt;
if (burnTimer >= burnDuration)
{
isBurning = false;
}
}重力の設定
フェーズ2では下方向へ重力加速度(-9.8)をかけていたが、まずは方向だけ地球(Sprite)の中心を向くようにした。距離に応じた計算になっておらず、まだ途中かけ。
void ApplyGravity()
{
Vector2 dir = (earth.position - transform.position).normalized;
Vector2 force = dir.normalized * earthgravity * rbRocket.mass;
rbRocket.AddForce(force);
}UIも少しだけ進化させて来たがここでは省略。
ここからの進め方
当初はChatGPTのおすすめで、フェーズ5で完了させるべく計画していた。ChatGPTからAntigravityメインにしたことで、細かい進め方は変わってくる可能性もあるが、ざっくりと各フェーズのゴール設定。
- フェーズ3: 地球の周りを周回できる状態。物理計算もある程度完了
- フェーズ4: 他の天体(月 or 火星)を追加。UIを使いやすくする
- フェーズ5: ステージを設けてゲーム感をだす。UI仕上げ
フェーズ3
やっとこの投稿の本題へ。
まずはSpriteの画像導入でも既に課題が見え始めたスケーリングについての仕組みを作っていく。最終的に太陽系全体まで扱うかどうかわからないが、地球をはじめ惑星の質力や速度をm/sで計算し始めるとfloatでは扱いきれなくなったり、描画領域が超巨大になったりと課題があるため、ゲームとして扱うにはやはりスケーリングが必要。
定数の設定
ゲーム内ではあくまで現実でありそうな数値で設定することを念頭に、メインのScriptとは別にPhysicsConstants.csというスクリプトを作成し、この中でUnity内での処理する数値に変換させる。
静的クラスとして作成しておくだけで、メインのRocketTest.csスクリプト内で自由に使用することができる。以下簡単に静的クラスの説明:
- 静的クラス(static class)とは: インスタンス(実体)を作らずに、どこからでも PhysicsConstants.ToUnityDistance(...) のように名前を指定するだけで呼び出せる「便利な道具箱」のようなもの
- メモリ上の扱い: ゲームが起動した瞬間に自動的に読み込まれ、どこからでもアクセス可能な状態になる
PhysicsConstants.cs
using UnityEngine;
///
/// 宇宙の物理定数と、現実単位(SI単位系)からUnity単位への変換を管理するクラス。
/// 宇宙の巨大な数値をUnityで扱いやすくするための「スケーリング」
///
public static class PhysicsConstants
{
// --- 現実の物理定数 (SI単位系) ---
public const float G = 6.67430e-11f; // 万有引力定数 [m^3 kg^-1 s^-2]
public const float EarthMass = 5.972e24f; // 地球の質量 [kg]
public const float EarthRadius = 6371000f; // 地球の半径 [m]
// --- スケーリング設定(1 Unity単位がどれくらいの現実量か) ---
// 距離: 1 Unity Unit = 100,000m (100km)
public const float DistanceScale = 100000f;
// 時間: 1 Unity Second = 60s (現実の1分)
// これにより、数時間かかる軌道投入も数分でシミュレーション可能になります。
public const float TimeScale = 60f;
// 質量: 1 Unity Mass = 1,000kg
public const float MassScale = 1000f;
// --- 変換メソッド ---
/// 現実の距離(m)をUnityの距離(Unit)に変換
public static float ToUnityDistance(float realDistance) => realDistance / DistanceScale;
/// Unityの距離(Unit)を現実の距離(m)に変換
public static float FromUnityDistance(float unityDistance) => unityDistance * DistanceScale;
/// 現実の質量(kg)をUnityの質量に変換
public static float ToUnityMass(float realMass) => realMass / MassScale;
/// 現実の推力(N)をUnityの力に変換
/// F = m * a より、ForceScale = MassScale * (DistanceScale / TimeScale^2)
public static float ToUnityForce(float realForce)
{
float accelerationScale = DistanceScale / (TimeScale * TimeScale);
return realForce / (MassScale * accelerationScale);
}
/// スケーリングされた万有引力定数μ (G * M)
/// Unity内の計算で毎回 G*M を計算する際の誤差と負荷を減らすため
public static float GetUnityMu(float realMass)
{
float realMu = G * realMass;
// Muの次元は [L^3 / T^2] なので、距離^3 / 時間^2 でスケール
return realMu / (Mathf.Pow(DistanceScale, 3) / Mathf.Pow(TimeScale, 2));
}
}地球のサイズ調整
地球画像修正
当初使用していた画像は、ChatGPTに生成してもらったままで余白を含んで1024x1024pxのサイズとなっていた。スケールを調整しやすくするため、無駄な余白をけした画像を作成。※ Antigravityでクロップをお願いしたらPythonスクリプトで何度か頑張ってくれたが、どうしても画像内のノイズのためかきれいに地球の境界線を見つけられず。あきらめてGIMPの使い方を教えてもらい(内容で切り抜き(Crop to Content)」機能)うまくいった。
UnityのAssetで読み込んだ画像(earth.png)を選択し、Pixels Per Unitのところに画像のピクセル数を入力。
UnityのAssetで読み込んだ画像(earth.png)を選択し、Pixels Per Unitのところに画像のピクセル数を入力。
| 今回の画像は634 x 634px |
| ScaleのX, Yを127.4に設定 |
| MainCameraのSizeを100にした時 |
カメラの設定
ひとまず地球のスケールは合ったが、Rocketのスケールはデフォルメする必要があり、画面も定点ではすぐに厳しくなることも予想できるため、カメラのズームを操作できるようにする。また、地球中心の画面とRocket追従画面を切り替えられるようにさせた。
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 = 0.1f;
public float minSize = 5f;
public float maxSize = 2500f;
private Camera cam;
private Transform currentTarget;
void Start()
{
cam = GetComponent<Camera>();
cam.orthographicSize = 100f;
// 初期状態はロケットを追従
currentTarget = rocket;
}
void LateUpdate()
{
// 1. キー入力によるターゲット切り替え
HandleTargetSwitch();
if (currentTarget == null) return;
// 2. ターゲットの追跡
Vector3 desiredPosition = currentTarget.position + offset;
Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed);
transform.position = smoothedPosition;
// 3. マウスホイールによるズーム
HandleZoom();
}
void HandleTargetSwitch()
{
if (Keyboard.current == null) return;
// 1キーでロケット、2キーで惑星に切り替え
if (Keyboard.current.digit1Key.wasPressedThisFrame)
{
currentTarget = rocket;
}
else if (Keyboard.current.digit2Key.wasPressedThisFrame)
{
currentTarget = planet;
}
}
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);
}
}
}
上記は100%Antigravity(Gemini 3 Flash)作成のまま手を付ける必要がなかった。内容は理解のため確認。
重力の計算
フェーズ2.5までは加速度一定、フェーズ3では万有引力の式で計算。
\( F = G\dfrac{Mm}{r^2} \)
void ApplyGravity()
{
Vector2 directionToEarth = (earth.position - transform.position);
float distanceUnits = directionToEarth.magnitude;
// 距離が近すぎると引力が無限大になるのを防ぐ (地球半径以下など)
if (distanceUnits < 0.1f) return;
// F = m * a より、加速度 a = G * M / r^2
// これをUnityスケーリングしたのが Mu / r^2
float muUnity = PhysicsConstants.GetUnityMu(earthMassReal);
float gravityAccel = muUnity / (distanceUnits * distanceUnits);
Vector2 force = directionToEarth.normalized * gravityAccel * rbRocket.mass;
rbRocket.AddForce(force);
}
推力の計算
フェーズ2.5でほぼほぼ出来上がっていたものをファインチューン。定数の換算を加えて、燃料の残りがなくなると燃焼が止まるように修正。
void ApplyThrust()
{
if (!isBurning) return;
float dtUnity = Time.fixedDeltaTime;
float dtReal = dtUnity * PhysicsConstants.TimeScale; // Unity時間を現実時間に変換
// 燃料消費計算 (現実単位)
float usedFuel = burnRateKgPerS * dtReal;
remainingFuelKg = Mathf.Max(0f, remainingFuelKg - usedFuel);
// 質量更新 (Unity単位)
rbRocket.mass = PhysicsConstants.ToUnityMass(dryMassKg + remainingFuelKg);
// 推力の適用 (現実ニュートンをUnityの力に変換)
float thrustUnity = PhysicsConstants.ToUnityForce(thrustPowerN);
Vector2 thrustDir = transform.up;
rbRocket.AddForce(thrustDir * thrustUnity);
burnTimerS += dtReal;
if (burnTimerS >= burnDurationS || remainingFuelKg <= 0)
{
isBurning = false;
}
}
軌道の可視化
当初目標としていた様な仕様はある程度組み込めたが、初期条件だけの指定ではやはり難易度が高い。Antigravityからのおすすめで、軌道を表示させられるようにした。将来的にはこういった機能はON/OFF設定をつけると良いかもしれない。
OrbitPredictor.cs
using UnityEngine;
using System.Collections.Generic;
///
/// 現在の速度と位置から、将来の飛行経路を計算して描画するクラス。
/// PhysicsConstants のスケーリングと万有引力の法則に基づきます。
///
[RequireComponent(typeof(LineRenderer))]
public class OrbitPredictor : MonoBehaviour
{
[Header("References")]
public RocketTest rocket;
public Transform earth;
[Header("Settings")]
public int stepCount = 500; // 予測するステップ数
public float timeStep = 0.1f; // 1ステップあたりのUnity時間(秒)
public float minPredictionDistance = 0.1f; // 地面にぶつかったら計算を止める距離
private LineRenderer lineRenderer;
private Rigidbody2D rbRocket;
void Awake()
{
lineRenderer = GetComponent();
// 線の見た目設定(初期値、Inspectorでも変更可能)
lineRenderer.startWidth = 0.2f;
lineRenderer.endWidth = 0.2f;
lineRenderer.positionCount = 0;
}
void Start()
{
if (rocket != null)
{
rbRocket = rocket.GetComponent();
}
}
void LateUpdate()
{
if (rocket == null || earth == null || rbRocket == null) return;
UpdatePrediction();
}
void UpdatePrediction()
{
List points = new List();
// --- シミュレーションの初期状態の設定 ---
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 = timeStep * PhysicsConstants.TimeScale;
points.Add(simPos);
for (int i = 0; i < stepCount; i++)
{
// 1. 重力加速度の計算
Vector2 diff = (Vector2)earth.position - simPos;
float distSq = diff.sqrMagnitude;
float dist = Mathf.Sqrt(distSq);
if (dist <= surfaceRadiusUnits) break;
Vector2 gravityDir = diff / dist;
float gravityAccel = muUnity / distSq;
Vector2 totalAccel = gravityDir * gravityAccel;
// 2. 推力の計算(噴射中の場合)
bool isSimBurning = simBurnTimer < rocket.burnDurationS && simRemainingFuel > 0;
float currentMassKg = rocket.dryMassKg + simRemainingFuel;
if (isSimBurning)
{
// 推力加速度 a = F / m
float thrustUnity = PhysicsConstants.ToUnityForce(rocket.thrustPowerN);
float massUnity = PhysicsConstants.ToUnityMass(currentMassKg);
totalAccel += simForward * (thrustUnity / massUnity);
// 燃料消費とタイマー更新
simRemainingFuel -= rocket.burnRateKgPerS * dtReal;
simBurnTimer += dtReal;
// 向きの更新(A案:進行方向に合わせる)
// 噴射中は速度が出るため、次のステップの速度を先読みして向きを変える
Vector2 nextVel = simVel + totalAccel * timeStep;
if (nextVel.sqrMagnitude > 0.001f)
{
simForward = nextVel.normalized;
}
}
else
{
// 慣性飛行中の向き更新
if (simVel.sqrMagnitude > 0.001f)
{
simForward = simVel.normalized;
}
}
// 3. 速度と位置の更新
simVel += totalAccel * timeStep;
simPos += simVel * timeStep;
points.Add(simPos);
if (dist > 10000f) break;
}
lineRenderer.positionCount = points.Count;
lineRenderer.SetPositions(points.ToArray());
}
}
フェーズ3の成果
| UnityのInspectorなので見えないが、初期設定を変えても軌道予測も変わる |
Antigravityが勝手に作成してくれるため、バイブコーディングと割り切って進めれば加速度的に早く完成しそうな状況。今のところはAntigravityがアウトプットした内容を一応自分でも確認&極力理解して進めている。
RocketTest.cs
using UnityEngine;
using UnityEngine.UIElements;
using UnityEngine.SceneManagement;
public class RocketTest : MonoBehaviour
{
[Header("Celestial Body (Earth)")]
public Transform earth;
public float earthMassReal = PhysicsConstants.EarthMass; // Inspectorで現実の質量(kg)を指定
[Header("Launch Condition")]
[Range(-90f, 90f)]
public float launchAngle = 45f;
[Header("Rocket Parameters (Real Units)")]
public float dryMassKg = 10000f; // 機体質量 [kg]
public float fuelMassKg = 40000f; // 燃料質量 [kg]
private float remainingFuelKg;
public float burnRateKgPerS = 500f; // 燃料消費率 [kg/s]
public float thrustPowerN = 2500000f; // 推力 [N] (10^6 Newtonクラス)
public float burnDurationS = 100f; // 噴射時間 [s]
private Vector3 initialPosition;
private Quaternion initialRotation;
private Vector2 velocity;
private Rigidbody2D rbRocket;
// Flag
private bool launched = false;
private bool isBurning = false;
private float burnTimerS = 0f;
private float currentGForce = 0f; // 現在のGフォース
// OrbitPredictorやHUDから参照するためのプロパティ
public bool IsLaunched => launched;
public bool IsBurning => isBurning;
public float RemainingFuelKg => remainingFuelKg;
public float BurnTimerS => burnTimerS;
public float CurrentGForce => currentGForce;
[Header("Visuals")]
public GameObject rocketBurnObject; // 噴射エフェクトのGameObject
[Header("UI")]
public UIDocument uiDocument;
private Button launchresetButton;
void Awake()
{
rbRocket = GetComponent<Rigidbody2D>();
rbRocket.gravityScale = 0f; // Unityの標準重力は使用しない
initialPosition = transform.position;
initialRotation = transform.rotation;
}
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
remainingFuelKg = fuelMassKg;
// 初期質量をUnity単位に変換してセット
rbRocket.mass = PhysicsConstants.ToUnityMass(dryMassKg + fuelMassKg);
// UI
launchresetButton = uiDocument.rootVisualElement.Q<Button>("LaunchResetButton");
launchresetButton.clicked += OnLaunchResetClicked;
UpdateButtonLabel();
}
// Update is called once per frame
void Update()
{
if (rocketBurnObject != null)
{
rocketBurnObject.SetActive(isBurning);
}
}
void FixedUpdate()
{
if (!launched || earth == null) return;
Vector2 lastVelocity = rbRocket.linearVelocity;
ApplyGravity();
ApplyThrust();
// 加速度 (Gフォース) の計算
// Unity単位の加速度 = (v_new - v_old) / dt
float accelUnity = (rbRocket.linearVelocity - lastVelocity).magnitude / Time.fixedDeltaTime;
// 現実の加速度 [m/s^2] = accelUnity * (DistanceScale / TimeScale^2)
float accelReal = accelUnity * (PhysicsConstants.DistanceScale / (PhysicsConstants.TimeScale * PhysicsConstants.TimeScale));
currentGForce = accelReal / 9.80665f;
// ロケットの向きを更新(進行方向に合わせる:A案)
UpdateRotation();
}
/// <summary>
/// 万有引力の法則 F = G * (M * m) / r^2 を適用
/// </summary>
void ApplyGravity()
{
Vector2 directionToEarth = (earth.position - transform.position);
float distanceUnits = directionToEarth.magnitude;
// 地表の半径(Unity単位)を取得
float surfaceRadiusUnits = PhysicsConstants.ToUnityDistance(PhysicsConstants.EarthRadius);
// 地表に到達(または食い込んだ)場合の処理
if (distanceUnits <= surfaceRadiusUnits)
{
// 速度を止める(簡易的な衝突・着陸判定)
rbRocket.linearVelocity = Vector2.zero;
isBurning = false;
return;
}
// F = m * a より、加速度 a = G * M / r^2
float muUnity = PhysicsConstants.GetUnityMu(earthMassReal);
float gravityAccel = muUnity / (distanceUnits * distanceUnits);
Vector2 force = directionToEarth.normalized * gravityAccel * rbRocket.mass;
rbRocket.AddForce(force);
}
void ApplyThrust()
{
if (!isBurning) return;
float dtUnity = Time.fixedDeltaTime;
float dtReal = dtUnity * PhysicsConstants.TimeScale; // Unity時間を現実時間に変換
// 燃料消費計算 (現実単位)
float usedFuel = burnRateKgPerS * dtReal;
remainingFuelKg = Mathf.Max(0f, remainingFuelKg - usedFuel);
// 質量更新 (Unity単位)
rbRocket.mass = PhysicsConstants.ToUnityMass(dryMassKg + remainingFuelKg);
// 推力の適用 (現実ニュートンをUnityの力に変換)
float thrustUnity = PhysicsConstants.ToUnityForce(thrustPowerN);
Vector2 thrustDir = transform.up;
rbRocket.AddForce(thrustDir * thrustUnity);
burnTimerS += dtReal;
if (burnTimerS >= burnDurationS || remainingFuelKg <= 0)
{
isBurning = false;
}
}
void UpdateRotation()
{
// 速度が極低速の場合は回転を更新しない(ガクつき防止)
if (rbRocket.linearVelocity.sqrMagnitude < 0.001f) return;
// 進行方向(Vector2)の角度を計算
float angle = Mathf.Atan2(rbRocket.linearVelocity.y, rbRocket.linearVelocity.x) * Mathf.Rad2Deg;
// ロケットの画像が「上(Up)」を向いている前提で、進行方向に合わせる(-90度調整)
transform.rotation = Quaternion.Euler(0f, 0f, angle - 90f);
}
public void Launch()
{
Vector2 launchDir = GetLaunchDirection();
transform.rotation = Quaternion.LookRotation(Vector3.forward, launchDir);
remainingFuelKg = fuelMassKg;
burnTimerS = 0f;
isBurning = true;
launched = true;
}
/// <summary>
/// 現在の設定(launchAngle)に基づいた発射方向ベクトルを取得
/// </summary>
public Vector2 GetLaunchDirection()
{
// 1. 地球中心へのベクトル
Vector2 toCenter = (earth.position - transform.position).normalized;
// 2. 接線方向(反時計回り)
Vector2 tangent = new Vector2(-toCenter.y, toCenter.x);
// 3. 発射角度の計算 (launchAngle = 0 で接線方向、90 で垂直方向)
float angleRad = launchAngle * Mathf.Deg2Rad;
return (Mathf.Cos(angleRad) * tangent) + (Mathf.Sin(angleRad) * (-toCenter));
}
// Reset Game
public void ResetRocket()
{
// 物理状態を止める
rbRocket.linearVelocity = Vector2.zero;
rbRocket.angularVelocity = 0f;
// 位置と回転を初期値に戻す
transform.position = initialPosition;
transform.rotation = initialRotation;
// ロケットの内部パラメータをリセット
remainingFuelKg = fuelMassKg;
rbRocket.mass = PhysicsConstants.ToUnityMass(dryMassKg + fuelMassKg);
burnTimerS = 0f;
launched = false;
isBurning = false;
}
void OnLaunchResetClicked()
{
if (!launched)
{
Launch();
}
else
{
ResetRocket();
}
UpdateButtonLabel();
}
void UpdateButtonLabel()
{
launchresetButton.text = launched ? "RESET" : "LAUNCH";
}
}
