【Unity 6】車や乗り物の汎用メーターUIをエディタ拡張で自動生成し、疎結合(Push型)で実装する方法

この記事は約19分で読めます。

ゲーム内に登場する車などの乗り物向けに、タコメーターやスピードメーターのUIを実装する手法を解説します。本記事のスクリプトはUnity 6で検証しています。

自分で微調整出来るメーターを都度スクリプトで作成できるスクリプトが欲しいと思ったので
作成しました。
もっと精度の高いアセットなどがあると思うのであまり需要はないかもしれませんが、
自分で作ってみたいのと、自分で微調整出来るものが欲しいと思ったので作成しました。

UIスクリプトが車の制御スクリプト(CarControllerなど)を直接参照する「Pull型」の設計にすると、別の乗り物や別のプロジェクトにUIを流用する際にエラーが発生しやすくなります。今回は、UI側は外部からデータを受け取るだけの「Push型」として設計し、完全に独立した使い回しやすいメーターUIモジュールを作成しました。

また、盤面のUI要素(目盛りやテキスト)を手作業で配置するのは非効率なため、エディタ拡張を利用して自動生成するツールも合わせて作成しています。

1. UIを制御する本体スクリプト(DashboardUI.cs)

このスクリプトは、渡された数値を画面の針の角度やテキストに反映する役割だけを持ちます。車の物理演算や状態には一切関与しません。

役割と特徴は以下の通りです。

  • 車のスクリプトへの依存がありません
  • 外部から SetMeterValues メソッドを呼び出して数値を流し込む(Pushする)ことで表示が更新されます

以下のC#スクリプトを DashboardUI.cs として保存してください。

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class DashboardUI : MonoBehaviour
{
    [Header("タコメーター(RPM)設定")]
    public RectTransform needleTransform;
    public float minRPM = 0f;
    public float maxRPM = 8000f;
    public float minRPMAngle = 135f;
    public float maxRPMAngle = -135f;

    [Header("テキスト・ゲージ設定")]
    public TextMeshProUGUI speedText;
    public TextMeshProUGUI gearText;
    public Image fuelGaugeImage;

    // --- 外部から数値を流し込むためのメソッド ---
    public void SetMeterValues(float currentRpm, float currentSpeedKph, int currentGear, float fuelRatio)
    {
        UpdateTachometer(currentRpm);
        UpdateSpeedometer(currentSpeedKph);
        UpdateGearDisplay(currentGear);
        UpdateFuelGauge(fuelRatio);
    }

    private void UpdateTachometer(float rpm)
    {
        if (needleTransform == null) return;
        float rpmNormalized = Mathf.InverseLerp(minRPM, maxRPM, rpm);
        float currentAngle = Mathf.Lerp(minRPMAngle, maxRPMAngle, rpmNormalized);
        needleTransform.localEulerAngles = new Vector3(0, 0, currentAngle);
    }

    private void UpdateSpeedometer(float speedKph)
    {
        if (speedText != null)
        {
            int speed = Mathf.FloorToInt(speedKph);
            speedText.text = $"{speed}<size=50%> km/h</size>";
        }
    }

    private void UpdateGearDisplay(int gear)
    {
        if (gearText != null)
        {
            gearText.text = gear.ToString();
        }
    }

    private void UpdateFuelGauge(float fuelRatio)
    {
        if (fuelGaugeImage != null)
        {
            fuelGaugeImage.fillAmount = Mathf.Clamp01(fuelRatio);
        }
    }
}

2. UI自動生成エディタ拡張(DashboardSetupWindow.cs)

手作業によるUI構築を省くためのエディタ拡張です。必ず Assets/Editor フォルダ内に配置します。

セットアップ方法と、使い方は以下の通りです。

  • Assets/Editor/にDashboardSetupWindow.csを保存
  • 上部メニューの Tools > Setup Dashboard UI から設定ウィンドウを開きます
  • 最大回転数やレッドゾーンの開始位置を入力し、「UIを作成する」ボタンを押すと、Canvasから目盛り、針、テキストまですべてのUIパーツを自動生成します
  • 生成時、盤面の開始値は常に「0」として目盛りを均等に配置します

なお、エディタ拡張で生成されるImageコンポーネント(目盛り、針、燃料ゲージ)にはSource Imageが設定されていないため、デフォルトの白い矩形として表示されます。見た目をカスタマイズする場合は、生成後にInspectorからSprite等を差し替えてください。使わないUI要素がある場合は、ヒエラルキーから該当オブジェクトを削除してください。

以下のC#スクリプトを Assets/Editor/DashboardSetupWindow.cs として保存してください。

using UnityEngine;
using UnityEditor;
using UnityEngine.UI;
using TMPro;

public class DashboardSetupWindow : EditorWindow
{
    private float maxRpm = 8000f;
    private float redZoneStart = 6000f;

    [MenuItem("Tools/Setup Dashboard UI")]
    public static void ShowWindow()
    {
        GetWindow<DashboardSetupWindow>("メーター生成設定");
    }

    private void OnGUI()
    {
        GUILayout.Label("タコメーターの生成設定", EditorStyles.boldLabel);

        maxRpm = EditorGUILayout.FloatField("最大回転数 (盤面の終了)", maxRpm);
        redZoneStart = EditorGUILayout.FloatField("レッドゾーン開始", redZoneStart);

        EditorGUILayout.Space();

        if (GUILayout.Button("UIを作成する"))
        {
            CreateDashboard();
            Close();
        }
    }

    private void CreateDashboard()
    {
        GameObject canvasObj = new GameObject("DashboardCanvas");
        Canvas canvas = canvasObj.AddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvasObj.AddComponent<CanvasScaler>().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        canvasObj.AddComponent<GraphicRaycaster>();
        Undo.RegisterCreatedObjectUndo(canvasObj, "Create Dashboard UI");

        GameObject managerObj = new GameObject("DashboardManager");
        managerObj.transform.SetParent(canvasObj.transform, false);
        DashboardUI dashboardUI = managerObj.AddComponent<DashboardUI>();

        // --- タコメーターのベースと盤面生成 ---
        GameObject tachoBaseObj = new GameObject("Tachometer_Base");
        tachoBaseObj.transform.SetParent(canvasObj.transform, false);
        RectTransform tachoRect = tachoBaseObj.AddComponent<RectTransform>();
        tachoRect.anchoredPosition = new Vector2(-200, 0);

        float tachoRadius = 150f;
        float startAngle = 135f;
        float endAngle = -135f;

        float baseMinRpm = 0f;
        int maxTick = Mathf.FloorToInt(maxRpm / 1000f);

        for (int i = 0; i <= maxTick; i++)
        {
            float currentRpm = i * 1000f;
            float t = Mathf.InverseLerp(baseMinRpm, maxRpm, currentRpm);
            float angle = Mathf.Lerp(startAngle, endAngle, t);
            
            float rad = angle * Mathf.Deg2Rad;
            Vector2 pos = new Vector2(-Mathf.Sin(rad) * tachoRadius, Mathf.Cos(rad) * tachoRadius);

            bool isRedZone = currentRpm >= redZoneStart;
            Color tickColor = isRedZone ? Color.red : Color.white;

            GameObject tickObj = new GameObject($"Tick_{i}");
            tickObj.transform.SetParent(tachoBaseObj.transform, false);
            Image tickImage = tickObj.AddComponent<Image>();
            tickImage.color = tickColor;
            RectTransform tickRt = tickObj.GetComponent<RectTransform>();
            tickRt.sizeDelta = new Vector2(4, 20);
            tickRt.anchoredPosition = pos;
            tickRt.localEulerAngles = new Vector3(0, 0, angle);

            GameObject labelObj = new GameObject($"Label_{i}");
            labelObj.transform.SetParent(tachoBaseObj.transform, false);
            TextMeshProUGUI labelText = labelObj.AddComponent<TextMeshProUGUI>();
            labelText.text = i.ToString();
            labelText.fontSize = 32;
            labelText.alignment = TextAlignmentOptions.Center;
            labelText.color = tickColor;
            RectTransform labelRt = labelObj.GetComponent<RectTransform>();
            labelRt.sizeDelta = new Vector2(50, 50);
            float labelRadius = tachoRadius - 40f;
            labelRt.anchoredPosition = new Vector2(-Mathf.Sin(rad) * labelRadius, Mathf.Cos(rad) * labelRadius);
        }

        // --- RPM針 ---
        GameObject needleObj = new GameObject("RPM_Needle");
        needleObj.transform.SetParent(tachoBaseObj.transform, false);
        Image needleImage = needleObj.AddComponent<Image>();
        needleImage.color = Color.red;
        RectTransform needleRect = needleObj.GetComponent<RectTransform>();
        needleRect.pivot = new Vector2(0.5f, 0f);
        needleRect.sizeDelta = new Vector2(8, tachoRadius - 15f);
        needleRect.anchoredPosition = Vector2.zero;
        needleRect.localEulerAngles = new Vector3(0, 0, startAngle);

        // --- スピードテキスト ---
        GameObject speedObj = new GameObject("Speed_Text");
        speedObj.transform.SetParent(tachoBaseObj.transform, false);
        TextMeshProUGUI speedText = speedObj.AddComponent<TextMeshProUGUI>();
        speedText.text = "0<size=50%> km/h</size>";
        speedText.fontSize = 64;
        speedText.alignment = TextAlignmentOptions.BottomRight;
        speedText.rectTransform.anchoredPosition = new Vector2(80, -100);

        // --- ギアテキスト ---
        GameObject gearObj = new GameObject("Gear_Text");
        gearObj.transform.SetParent(tachoBaseObj.transform, false);
        TextMeshProUGUI gearText = gearObj.AddComponent<TextMeshProUGUI>();
        gearText.text = "1";
        gearText.fontSize = 48;
        gearText.alignment = TextAlignmentOptions.Center;
        gearText.rectTransform.anchoredPosition = new Vector2(0, -60);

        // --- 燃料ゲージ ---
        GameObject fuelObj = new GameObject("Fuel_Gauge");
        fuelObj.transform.SetParent(canvasObj.transform, false);
        Image fuelImage = fuelObj.AddComponent<Image>();
        fuelImage.type = Image.Type.Filled;
        fuelImage.fillMethod = Image.FillMethod.Vertical;
        fuelImage.fillOrigin = (int)Image.OriginVertical.Bottom;
        fuelImage.fillAmount = 1f;
        RectTransform fuelRt = fuelObj.GetComponent<RectTransform>();
        fuelRt.anchoredPosition = new Vector2(-400, -150);
        fuelRt.sizeDelta = new Vector2(20, 100);

        // 4. DashboardUIへの参照割り当て
        SerializedObject serializedObject = new SerializedObject(dashboardUI);
        serializedObject.FindProperty("needleTransform").objectReferenceValue = needleRect;
        serializedObject.FindProperty("speedText").objectReferenceValue = speedText;
        serializedObject.FindProperty("gearText").objectReferenceValue = gearText;
        serializedObject.FindProperty("fuelGaugeImage").objectReferenceValue = fuelImage;
        
        serializedObject.FindProperty("minRPM").floatValue = baseMinRpm;
        serializedObject.FindProperty("maxRPM").floatValue = maxRpm;
        serializedObject.FindProperty("minRPMAngle").floatValue = startAngle;
        serializedObject.FindProperty("maxRPMAngle").floatValue = endAngle;
        
        serializedObject.ApplyModifiedProperties();

        Debug.LogWarning("ダッシュボードUIの生成が完了しました。");
    }
}

3. 動作確認用のダミーデータ送信スクリプト(DummyMeterTester.cs)

物理演算の車がなくても、UI単体で動作確認をするためのテスト用スクリプトです。

役割と特徴は以下の通りです。

  • キー入力に応じて数値を加算・減算し、DashboardUI.SetMeterValues を毎フレーム呼び出してUIに送信します
  • アイドリング状態(アクセルオフ時に針が800rpmまで下がる挙動)を独自にシミュレートしています

以下のC#スクリプトを DummyMeterTester.cs として保存してください。

using UnityEngine;

public class DummyMeterTester : MonoBehaviour
{
    [Header("テスト対象のダッシュボードUI")]
    public DashboardUI dashboardUI;

    [Header("エンジン設定 (テスト用)")]
    public float idleRpm = 800f;  // アイドリング回転数
    public float maxRpm = 8000f;

    private float testRpm = 0f;
    private float testSpeed = 0f;
    private int testGear = 1;
    private float testFuel = 1f;

    private void Start()
    {
        testRpm = idleRpm;
    }

    private void Update()
    {
        if (dashboardUI == null) return;

        float input = Input.GetAxis("Vertical");

        testSpeed = Mathf.Clamp(testSpeed + input * 80f * Time.deltaTime, 0f, 200f);

        if (input > 0)
        {
            testRpm = Mathf.Clamp(testRpm + input * 5000f * Time.deltaTime, idleRpm, maxRpm);
        }
        else
        {
            // ダミー独自の処理:アクセルオフ時は強制的にアイドリングまでLerpさせる
            testRpm = Mathf.Lerp(testRpm, idleRpm, Time.deltaTime * 2f);
        }

        testGear = 1 + Mathf.FloorToInt(testSpeed / 40f);
        if (testGear > 5) testGear = 5;

        testFuel -= 0.01f * Time.deltaTime;
        if (testFuel < 0f) testFuel = 1f;

        // UIに数値を毎フレーム送信
        dashboardUI.SetMeterValues(testRpm, testSpeed, testGear, testFuel);
    }
}

4. ダミーデータから本番環境(実際のゲームロジック)への移行手順

新しいプロジェクトなどで実際の車の制御スクリプト(CarControllerなど)にこのUIを組み込む際は、以下の対応を行います。

4-1. 依存関係の接続

本番の車両制御スクリプト側に、UIを参照するための変数を追加します。

public DashboardUI dashboardUI;

4-2. データ送信処理の追加

車両制御スクリプトの Update または FixedUpdate メソッド内の最後に、以下の1行を追加して毎フレーム現在のステータスを送信します。

if (dashboardUI != null)
{
    dashboardUI.SetMeterValues(CurrentRPM, CurrentSpeedKPH, CurrentGearIndex, FuelRatio);
}

4-3. 回転の落ち方(エンジンブレーキ等)の制御について

DummyMeterTester.cs では「アクセルを離すと無条件でアイドリング回転数まで落ちる」処理(Lerp)を記述していましたが、本番環境のUIスクリプト側にはそのような処理を書いてはいけません。

UI側はあくまで「送られてきた数値を表示するだけのモニター」です。アクセルを離した際のエンジンブレーキの効き具合や、クラッチを切った際の回転数の落ち方、アイドリングの維持などは、すべて車両制御側のスクリプト(データの送信元)の物理計算で規定し、算出されたRPMの数値をそのままUIに渡す設計にしてください。

※※※注 本スクリプトに関してはあくまでUnity6の私の環境で動作したものとなるため、
環境如何によっては動作しない可能性があります。参考としてみていただければ
と思います。

コメント

タイトルとURLをコピーしました