仕事でバタバタとしている間に半年間Blog放置となっていた。放置直前には『Launch Condition』という名称でUnityを使ってゲームを作成していたが、手を付けていない間にOSアップデート時の不注意からデータを喪失してしまい、Unity Version Controlを使っても中途半端にしか復旧できなかった。続きから再開を試みても良かったのだが、途中からコードを見返して整理するのも怠く感じたため、改めてやり直してみることにした。
| 以前とコンセプトは変えず、設定した初期力(初速)と角度で発射された物体(Probe)をターゲットに当てるゲーム。途中に配置された惑星(Planet)の引力により軌道が変化する。 |
以前と同様にAIの力を借りているが、また長期間放置しかねないのでその際にも理解できるように整理しながら進める。以下主なObjectとそれに紐づくScript。
探査機『Probe』の設定
GameObject『Probe』
- [2D Object] ➔ [Sprites] ➔ [Circle] を作成
- Transform: Positionで位置、Scaleでサイズ調整
- Component 追加:
- Inspector の [Add Component] から Rigidbody 2D を追加
- Gravity Scale: 0 に変更(宇宙空間のため無重力にする)
- Circle Collider 2D を追加(デフォルトのままでOK)
LaunchProbe.cs
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections.Generic;
public class LaunchProbe : MonoBehaviour
{
[Header("Launch Settings")]
public float launchAngle = 15f;
public float launchForce = 8f;
[Header("References")]
[SerializeField] private UIManager uiManager;
// 単一参照からリスト管理へ変更(Inspectorから手動設定も可能)
[SerializeField] private List<GravityPlanet> planets = new List<GravityPlanet>();
[Header("Physical Settings")]
[SerializeField] private float mass = 1f;
public float Mass => mass;
private Rigidbody2D rb;
private bool isLaunched = false;
public bool IsLaunched => isLaunched; // UIManager から参照用
// 初期状態の記録用
private Vector3 initialPosition;
private Quaternion initialRotation;
private bool isEnded = false; // クリアまたは失敗で終了したかどうかのフラグ
private Camera mainCamera;
void Awake()
{
rb = GetComponent<Rigidbody2D>();
rb.gravityScale = 0f;
// 初期位置と回転を記録
initialPosition = transform.position;
initialRotation = transform.rotation;
mainCamera = Camera.main;
// Inspectorで指定されていない場合、シーン上のすべての GravityPlanet を自動検出
RefreshPlanets();
}
///
/// シーン上のすべての惑星を取得(動的生成・配置に対応)
///
public void RefreshPlanets()
{
if (planets == null || planets.Count == 0)
{
planets = new List<GravityPlanet>(FindObjectsByType<GravityPlanet>(FindObjectsSortMode.None));
}
}
void Update()
{
// Spaceキーで発射
if (Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame && !isLaunched)
{
Launch();
}
// Rキーでいつでも初期位置にリセット
if (Keyboard.current != null && Keyboard.current.rKey.wasPressedThisFrame)
{
ResetProbe();
}
// 画面外チェック(発射済みかつ未終了時のみ)
if (!isEnded && rb.simulated)
{
CheckOutOfBounds();
}
}
void FixedUpdate()
{
// 発射済み、未終了、かつ惑星が存在する場合に常時合成重力を計算・適用
if (isLaunched && !isEnded && rb.simulated && planets.Count > 0)
{
ApplyCombinedGravity();
}
}
///
/// 全惑星からの合成重力(万有引力のベクトル和)の適用
///
private void ApplyCombinedGravity()
{
Vector2 totalGravityForce = Vector2.zero;
foreach (var planet in planets)
{
if (planet == null) continue;
Vector2 direction = (Vector2)planet.transform.position - rb.position;
float distance = direction.magnitude;
// ゼロ除算・極端な中心近接時の計算破綻防止ガード(0.1f以上で計算)
if (distance > 0.1f)
{
// 個別惑星からの重力 F/m = a = G / r^2
Vector2 gravityForce = direction.normalized * (planet.GravityConstant / (distance * distance));
totalGravityForce += gravityForce; // ベクトル加算(合成)
}
}
// 合成された力を加算
rb.AddForce(totalGravityForce, ForceMode2D.Force);
}
public void Launch()
{
if (isLaunched) return;
isLaunched = true;
float rad = launchAngle * Mathf.Deg2Rad;
Vector2 dir = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad));
rb.AddForce(dir * launchForce, ForceMode2D.Impulse);
if (uiManager != null)
{
uiManager.SetButtonToReset();
uiManager.SetControlsEnabled(false); // UI操作無効化
}
}
public void ResetProbe()
{
isLaunched = false;
// 物理の動きと回転を完全停止
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
// 初期位置と回転に戻す
transform.position = initialPosition;
transform.rotation = initialRotation;
isEnded = false;
rb.simulated = true; // 物理計算を再開
if (uiManager != null)
{
uiManager.HideStatusUI();
uiManager.SetButtonToLaunch();
uiManager.SetControlsEnabled(true); // UI操作有効化
}
}
///
/// 画面外に出たかどうか判定
///
private void CheckOutOfBounds()
{
Vector3 screenPoint = mainCamera.WorldToViewportPoint(transform.position);
// Viewport 座標で 0.0 ~ 1.0 の範囲外に出たかチェック(マージン付き)
if (screenPoint.x < -0.1f || screenPoint.x > 1.1f || screenPoint.y < -0.1f || screenPoint.y > 1.1f)
{
StopPhysicsAndFail("FAILED! (Out of Bounds)");
}
}
///
/// 失敗時の処理(デフォルト)
///
public void StopPhysicsAndFail()
{
StopPhysicsAndFail("FAILED!");
}
///
/// 失敗時の処理(理由メッセージ指定版)
///
public void StopPhysicsAndFail(string reason)
{
if (isEnded) return;
isEnded = true;
StopPhysics();
if (uiManager != null)
{
uiManager.ShowFailUI(reason);
}
}
///
/// クリア成功時の処理
///
public void StopPhysicsAndClear()
{
if (isEnded) return;
isEnded = true;
StopPhysics();
if (uiManager != null)
{
uiManager.ShowClearUI();
}
}
///
/// 物理計算と速度を完全停止
///
private void StopPhysics()
{
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
rb.simulated = false; // 物理計算・動的更新を停止
}
}惑星『Planet』
GameObject『Planet』
- [2D Object] ➔ [Sprites] ➔ [Circle] を作成
- Transform: Positionで位置、Scaleでサイズ調整
- Component 追加:
- Circle Collider 2D を追加
- Is Trigger: チェックはずす
GravityPlanet.cs
using UnityEngine;
public class GravityPlanet : MonoBehaviour { [SerializeField] private float gravityConstant = 1000f; public float GravityConstant => gravityConstant; private void OnCollisionEnter2D(Collision2D collision) { // 型引数 を明示し、取得した probe インスタンスからメソッドを呼び出す if (collision.gameObject.TryGetComponent(out LaunchProbe probe)) { probe.StopPhysicsAndFail("FAILED! (CRASHED!)"); } } }
ターゲット『Target』
GameObject『Target』
- [2D Object] ➔ [Sprites] ➔ [Circle] を作成
- Transform: Positionで位置、Scaleでサイズ調整
- Component 追加:
- Circle Collider 2D を追加
- Is Trigger: チェック
- 黄色など目立つ色に変更
Target.cs
using UnityEngine;
public class Target : MonoBehaviour
{
private void OnTriggerEnter2D(Collider2D other)
{
LaunchProbe probe = other.GetComponent<LaunchProbe>();
if (probe != null)
{
probe.StopPhysicsAndClear();
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
LaunchProbe probe = collision.gameObject.GetComponent<LaunchProbe>();
if (probe != null)
{
probe.StopPhysicsAndClear();
}
}
}軌道予測線
GameObject『TrajectoryLine』
- [Effects] ➔ [Line] から、LineRendererを追加
- 線色・線幅を調整(グラデーションをつけて、遠くに行くほど暗くなるように)
- [Effects] ➔ [Line] から、LineRendererを追加
- 線色・線幅を調整(グラデーションをつけて、遠くに行くほど暗くなるように)
TrajectoryPredictor.cs
using UnityEngine;
using System.Collections.Generic;
[RequireComponent(typeof(LineRenderer))]
public class TrajectoryPredictor : MonoBehaviour
{
[Header("References")]
[SerializeField] private LineRenderer lineRenderer;
[SerializeField] private LaunchProbe probe; // Probe側のスクリプトを参照
// 単一参照から複数管理用リストへ変更
[SerializeField] private List<GravityPlanet> planets = new List<GravityPlanet>();
[Header("Simulation Settings")]
[SerializeField] private int maxSteps = 100;
[SerializeField] private float timeStep = 0.05f;
private void Awake()
{
if (lineRenderer == null)
{
lineRenderer = GetComponent<LineRenderer>();
}
// Inspectorで指定されていない場合、シーン上のすべての GravityPlanet を自動検出
RefreshPlanets();
}
///
/// シーン上のすべての惑星を取得(動的生成・配置に対応)
///
public void RefreshPlanets()
{
if (planets == null || planets.Count == 0)
{
planets = new List<GravityPlanet>(FindObjectsByType<GravityPlanet>(FindObjectsSortMode.None));
}
}
public void PredictTrajectory(float angleDeg, float force)
{
if (lineRenderer == null || probe == null || planets == null || planets.Count == 0) return;
lineRenderer.enabled = true;
lineRenderer.positionCount = maxSteps;
Vector3 currentPosition = probe.transform.position;
// 1. LaunchProbe 側と同一の初速計算(Impulse モード: v0 = F / m)
float angleRad = angleDeg * Mathf.Deg2Rad;
Vector3 currentVelocity = new Vector3(Mathf.Cos(angleRad), Mathf.Sin(angleRad), 0f) * (force / probe.Mass);
for (int i = 0; i < maxSteps; i++)
{
lineRenderer.SetPosition(i, currentPosition);
// 2. LaunchProbe.cs と完全に同一の合成重力ベクトル(加速度)を計算
Vector3 totalAcceleration = Vector3.zero;
foreach (var planet in planets)
{
if (planet == null) continue;
Vector3 directionToCenter = planet.transform.position - currentPosition;
float distance = directionToCenter.magnitude;
// ゼロ除算・極端な中心近接時の計算破綻防止ガード(0.1f以上で計算)
if (distance > 0.1f)
{
// 加速度 a = G / r^2
Vector3 accel = directionToCenter.normalized * (planet.GravityConstant / (distance * distance));
totalAcceleration += accel; // ベクトル加算(合成)
}
}
// 3. オイラー法による速度・位置更新(LaunchProbe の FixedUpdate と同一)
currentVelocity += totalAcceleration * timeStep;
currentPosition += currentVelocity * timeStep;
}
}
public void SetLineVisible(bool visible)
{
if (lineRenderer != null)
{
lineRenderer.enabled = visible;
}
}
}UI設定
UI設定
- [UI Toolkit] ➔ [UI Document]
- Assets内のUI Toolkitフォルダ → MainUXMLをダブルクリックし、UI Builderで設定
- Force / Angle SliderとTextをVisual Elementsにまとめる
- Launch / Reset用ボタン追加
- Fail/Clear表示用Label追加
UIManager.cs
using UnityEngine;
using UnityEngine.UIElements;
public class UIManager : MonoBehaviour
{
[Header("References")]
[SerializeField] private UIDocument uiDocument;
[SerializeField] private LaunchProbe probe;
[SerializeField] private TrajectoryPredictor trajectoryPredictor;
private Slider angleSlider;
private Label angleSliderLabel;
private Slider forceSlider;
private Label forceSliderLabel;
private Label clearLabel;
// トグル用のアクションボタン
private Button actionButton;
private void OnEnable()
{
if (uiDocument == null)
{
uiDocument = GetComponent<UIDocument>();
}
if (uiDocument == null) return;
VisualElement root = uiDocument.rootVisualElement;
if (root != null)
{
root.RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
}
}
private void OnDisable()
{
if (uiDocument != null && uiDocument.rootVisualElement != null)
{
uiDocument.rootVisualElement.UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
}
if (actionButton != null)
{
actionButton.clicked -= OnActionButtonClicked;
}
}
private void OnGeometryChanged(GeometryChangedEvent evt)
{
VisualElement root = uiDocument.rootVisualElement;
if (root == null) return;
root.UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
InitializeUI(root);
}
private void InitializeUI(VisualElement root)
{
angleSlider = root.Q<Slider>("AngleSlider");
angleSliderLabel = root.Q<Label>("AngleSliderLabel");
forceSlider = root.Q<Slider>("ForceSlider");
forceSliderLabel = root.Q<Label>("ForceSliderLabel");
clearLabel = root.Q<Label>("ClearLabel");
if (clearLabel != null)
{
clearLabel.style.display = DisplayStyle.None;
}
// ActionButton の取得と設定
actionButton = root.Q<Button>("ActionButton");
if (actionButton != null)
{
actionButton.text = "LAUNCH";
actionButton.clicked += OnActionButtonClicked;
}
else
{
Debug.LogWarning("[UIManager] 'ActionButton' が見つかりません。Name属性を確認してください。");
}
if (angleSlider != null)
{
angleSlider.RegisterValueChangedCallback(e => OnAngleChanged(e.newValue));
OnAngleChanged(angleSlider.value);
}
if (forceSlider != null)
{
forceSlider.RegisterValueChangedCallback(e => OnForceChanged(e.newValue));
OnForceChanged(forceSlider.value);
}
// ★ 初期表示用に初回計算を強制実行する
if (trajectoryPredictor != null && angleSlider != null && forceSlider != null)
{
trajectoryPredictor.PredictTrajectory(angleSlider.value, forceSlider.value);
}
}
private void OnActionButtonClicked()
{
if (probe == null) return;
// 探査機が発射済みかどうかで処理と表示を判定
if (!probe.IsLaunched)
{
probe.Launch();
SetButtonToReset();
}
else
{
probe.ResetProbe();
SetButtonToLaunch();
}
}
public void SetControlsEnabled(bool enabled)
{
if (angleSlider != null)
{
angleSlider.SetEnabled(enabled);
}
if (forceSlider != null)
{
forceSlider.SetEnabled(enabled);
}
if (trajectoryPredictor != null)
{
trajectoryPredictor.SetLineVisible(enabled);
}
}
public void SetButtonToLaunch()
{
if (actionButton != null)
{
actionButton.text = "LAUNCH";
}
}
public void SetButtonToReset()
{
if (actionButton != null)
{
actionButton.text = "RESET";
}
}
private void OnAngleChanged(float newValue)
{
if (angleSliderLabel != null)
{
angleSliderLabel.text = $"Angle: {newValue:F1} deg";
}
if (probe != null)
{
probe.launchAngle = newValue;
}
// 発射前のみ軌道線をリアルタイム更新
if (probe != null && !probe.IsLaunched && trajectoryPredictor != null)
{
trajectoryPredictor.PredictTrajectory(newValue, forceSlider != null ? forceSlider.value : 0f);
}
}
private void OnForceChanged(float newValue)
{
if (forceSliderLabel != null)
{
forceSliderLabel.text = $"Force: {newValue:F1} N";
}
if (probe != null)
{
probe.launchForce = newValue;
}
// 発射前のみ軌道線をリアルタイム更新
if (probe != null && !probe.IsLaunched && trajectoryPredictor != null)
{
trajectoryPredictor.PredictTrajectory(angleSlider != null ? angleSlider.value : 0f, newValue);
}
}
public void ShowClearUI()
{
if (clearLabel != null)
{
clearLabel.text = "CLEAR!";
clearLabel.style.color = new StyleColor(Color.green);
clearLabel.style.display = DisplayStyle.Flex;
}
}
public void ShowFailUI(string message = "FAILED!")
{
if (clearLabel != null)
{
clearLabel.text = message;
clearLabel.style.color = new StyleColor(Color.red);
clearLabel.style.display = DisplayStyle.Flex;
}
}
public void HideStatusUI()
{
if (clearLabel != null)
{
clearLabel.style.display = DisplayStyle.None;
}
}
}久々にUnityを再開して見た目は地味だが第一弾としてはまずまず作成を進めることができた。今回はGithubへ保存したため、データはある程度安全にバックアップできている。
今後継続できるかどうかは、このゲームのゴール設定次第。あまり焦らず気が向いたときに再開したい。
