5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UnityAdvent Calendar 2024

Day 4

Unityエディタのオペレーション運用を見据えたコンポーネント設計の提案

Last updated at Posted at 2024-12-04

この記事は、Unity Advent Calendar 2024 (シリーズ1) 4日目の記事です。

はじめに

Unityを使って開発を行う際、主軸となる成果物はMonoBehaviorを継承したコンポーネントスクリプトです。StartUpdateなど、あらかじめ用意されたライフタイムイベントに処理を記述し、シーン上のGameObjectにアタッチするだけで動作するため、直感的で分かりやすい仕組みといえます。このおかげで、Unityでは初心者でも比較的簡単に自分のアイデアを形にできる環境が整っています。

しかし、Unityの活用方法はゲーム開発だけに留まりません。アプリケーションをパッケージングして配布するというよりも、Unityエディタをオペレーションツールとして活用するケースも多く見受けられます。この場合、シーンの組み立てという基本的なワークフローは変わらないものの、コンポーネントスクリプト自体がツールのひとつとして利用される場面が頻繁に発生します。

たとえば、Unity公式のCameraLightコンポーネントは、内部構造を把握せずとも直感的に扱えるよう設計されています。これと同じように、自分で作成したコンポーネントスクリプトをツールとして使いやすく設計するにはどうすればよいでしょうか?

表面的な解法でいえば、カスタムエディタスクリプトによるUIのカスタマイズが挙げられます。コンポーネントスクリプト単体でも、UnityがそれらしくUIを生成してくれますが、カスタムエディタスクリプト使用すれば、UIをより柔軟にカスタマイズすることが可能です。

自動生成されたコンポーネントUI
自動生成されたコンポーネントUI

カスタムエディタによって調整されたコンポーネントUI
カスタムエディタによって調整されたコンポーネントUI

しかしUIをカスタマイズすれば十分というわけではなく、コンポーネントを取り巻く設計の良し悪しが根本的な課題として立ちはだかります。扱いやすさを追求しすぎるあまり、機能を過剰に詰め込んでコンポーネントが肥大化したり、逆に必要以上に分割して管理が煩雑になってしまいます。このような場合、全体の保守性が低下し、表向きはシンプルでも運用しづらいコンポーネントになるでしょう。

この記事では、Unityエディタをそのまま使用するオペレーション運用を前提とした設計案をご紹介します。これが絶対的な正解というわけではないですが、指針を決めるための一助にはなるかと思います。なおプロジェクト規模の開発限らず、ちょっとしたツールとしてUnityを使っている方々に向けて、詳細な設計の話まではなるべく言及しません。またUnityを使った開発に限らず、スクリプトの役割分担や全体構造の設計例としても、ご参照ください。

全体構造

まず最初に、今回提案するスクリプト群と設計の全体構造をお見せします。
UnityArchitecture_241203.png

パッと見た感じ、登場人物が多く複雑性を増しているような印象を受けるかもしれません。しかしながら、依存関係の方向性は概ね整理された状態となっています。概要だけの説明では伝わりにくいかと思いますので、具体的な実装例を交えつつ、構造を分解して詳細をお話します。

コンポーネントとモジュールの定義

コンポーネントは、Unityのライフタイムサイクルと強調して動作するスクリプトです。MonoBehaviourを継承したスクリプトで、用意されたライフタイムイベント(メソッド)に処理を記述していきます。ここにあらゆる実装を詰め込んでしまうと、先のような問題が発生し、保守性が低下してしまいます。

ではどのように実装すべきかというお話ですが、まずはコンポーネントスクリプトの役割を明確にします。絶対ではありませんが、基本は大まかに下記3つの役割を任せます。

  • サブ機能の初期化
  • Unityライフタイムイベントによる処理の呼び出し
  • 例外処理とログ出力

ベースラインとして、Unityのライフタイムサイクルに依存する処理を基本とします。特に初期化に関して、言い換えればエントリーポイントの役割に該当します。そこを起点に、それぞれのサブ機能はモジュールとして初期化されます。そしてライフタイムサイクルに従いながらも、それぞれのコンポーネントは独立して動作します。

サブ機能として実装されたスクリプト群を、ここではモジュールとして便宜的に呼称します。その実体は一般的なC#スクリプトであり、SOLID原則のような普遍的な設計方針に則った実装が好ましいです。この話はあらゆるところで既に多く語られており、要はUnity独自の仕組みに依存する必要のない処理はなるべく分離しましょうということです。

全体の登場人物は多いですが、このコンポーネントとモジュールが最低限揃っていれば、機能の実装と稼働は可能となります。

実装サンプル

それでは、具体的な例を挙げてサンプル実装を組み上げてみましょう。今回は日常的に使用されている「壁面コンセント (Outlet) 1 」をコンポーネントとして実装します。

実装サンプルはGitHubにもアップロードしていますので、併せてご参照ください。

論理設計

実装の前に、コンセントが備えているインターフェースを整理します。物理的なインターフェースはシンプルで、機器を接続する「プラグソケット」のみを備えています。また抽象的な観点として、電力の「供給者」と「消費者」という関係性も明確にしておく必要があります。

これら3つをインターフェースとして実装しつつ、壁面コンセントそのものを示すコンポーネントとモジュールを設計しましょう。

コンポーネントとモジュールの実装

論理設計に従って、コンポーネントスクリプトとモジュールスクリプトを実装します。インターフェースを利用してなるべく汎化するような実装となっていますが、コードの記述は省きます。
UnityArchitecture_part1_241203.png

using System;
using System.Collections.Generic;
using UnityEngine;

namespace PowerEquipment
{
    public class OutletComponent : MonoBehaviour
    {
        private IPowerSupplier _supplier;

        private void OnEnable()
        {
            var sockets = new List<IPowerSocket>(2);
            try
            {
                _supplier = new OutletModule(sockets, new Power(1500));
            }
            catch (Exception e)
            {
                Debug.LogError($"モジュールの初期化に失敗しました: {e.Message}");
            }

            Debug.Log($"最大消費電力: {_supplier.Capacity.Watts} [W]");
            Debug.Log($"現在の消費電力: {_supplier.Consumption.Watts} [W]");
        }

        private void OnDisable()
        {
            _supplier = null;
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;

namespace PowerEquipment
{
    public class OutletModule : IPowerSupplier
    {
        public static readonly Power DefaultCapacity = new(1500);

        private readonly List<IPowerSocket> _powerSockets;
        public Power Capacity { get; }
        public Power Consumption => new(_powerSockets.Sum(socket => socket.Consumption.Watts));

        public OutletModule(List<IPowerSocket> sockets, Power capacity)
        {
            _powerSockets = sockets;
            Capacity = capacity ?? DefaultCapacity;
        }

        public void Connect(int index, IPowerConsumer consumer)
        {
            ValidateSocketIndex(index);
            _powerSockets[index].Connect(consumer);
        }

        public void Disconnect(int index)
        {
            ValidateSocketIndex(index);
            _powerSockets[index].Disconnect();
        }

        private void ValidateSocketIndex(int index)
        {
            if (index < 0 || index >= _powerSockets.Count)
            {
                throw new ArgumentOutOfRangeException(nameof(index), index, "指定されたソケットは存在しません。");
            }
        }
    }
}
namespace PowerEquipment
{
    public class PowerSocket : IPowerSocket
    {
        private IPowerConsumer _consumer;

        public bool IsConnected => _consumer != null;
        public Power Consumption => _consumer?.Consumption ?? new Power(0);

        public PowerSocket(IPowerConsumer consumer = null)
        {
            Connect(consumer);
        }

        public void Connect(IPowerConsumer consumer)
        {
            _consumer = consumer;
        }

        public void Disconnect()
        {
            _consumer = null;
        }
    }
}

シーンに適当なオブジェクトを配置し、作成したOutletComponentをアタッチしましょう。そのままUnityエディタを再生すると、コンソールにデバッグログが出力されます。
スクリーンショット 2024-12-03 16.02.07.png

この時点の実装では、空ソケットを2つと、最大消費電力として1500Wという値をOutletComponentにてハードコードし、OutletModuleに渡しています。デバッグログの出力自体はその結果に基づいているものなので、特段問題なさそうです。

設定項目を実装する

コアロジックが実装できたので、次は入力となる設定項目を実装します。SerializeFieldを利用して、エディタUIから設定値を取得して保持できるようにしましょう。
UnityArchitecture_part2_241203.png

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace PowerEquipment
{
    public class OutletComponent : MonoBehaviour
    {
        // 追加
        [SerializeField] private int capacity = 1500;
        [SerializeField] private List<PowerSocketField> socketFields;

        private IPowerSupplier _supplier;

        private void OnEnable()
        {
            try
            {
                // 追加
                var sockets = socketFields
                    .Select(field => new PowerSocket(field.ConsumerComponent))
                    .Cast<IPowerSocket>()
                    .ToList();

                _supplier = new OutletModule(sockets, new Power(capacity));
            }
            catch (Exception e)
            {
                Debug.LogError($"モジュールの初期化に失敗しました: {e.Message}");
                return;
            }

            Debug.Log($"最大消費電力: {_supplier.Capacity.Watts} [W]");
            Debug.Log($"現在の消費電力: {_supplier.Consumption.Watts} [W]");
        }

        private void OnDisable()
        {
            _supplier = null;
        }
    }
}
using UnityEngine;

namespace PowerEquipment
{
    public abstract class PowerConsumerComponent : MonoBehaviour, IPowerConsumer
    {
        public abstract Power Consumption { get; }
    }
}
using System;
using UnityEngine;

namespace PowerEquipment
{
    [Serializable]
    public class PowerSocketField
    {
        [SerializeField] private PowerConsumerComponent consumerComponent;
        
        public PowerConsumerComponent ConsumerComponent => consumerComponent;
    }
}

この実装が追加されることにより、OutletComponentのエディタUIにて設定を入力できるようになりました。
スクリーンショット 2024-12-03 18.00.25.png

ここでは新たにPowerConsumerComponentというコンポーネントが登場しました。これはIPowerConsumerを実装した抽象クラスで、コンセントに接続する機器を表します。エディタUIでは同じく追加したPowerSocketFieldクラス経由し、間接的に接続機器を設定するようにします。

SerializeField が付与されたフィールドは、当然ながら Serializable であることが前提条件となります。PowerPowerSocket クラスを Serializable に設定することで、Unity エディタから直接参照させることも可能ですが、クラスの不変性が損なわれたり、不必要な実装上の制約が発生する可能性があります。さらに、後述するエディタスクリプトの実装においても問題が生じる恐れがあるため、少々手間がかかるように見えても、インスペクタから値を受け取って保持する専用のフィールドクラスを定義する方が、結果としてより適切なアプローチと言えるでしょう。その役割を担うのが、PowerSocketFieldクラスです。

参照先のコンポーネントを実装する

それらしいUIが見えるようになりましたが、接続機器を示すPowerConsumerComponentは抽象クラスのため、実体が用意できていません。次はPowerConsumerComponentを継承した具像クラスを実装していきましょう。今回は簡単な例としてプリンターとシュレッダーを接続機器として定義します。
UnityArchitecture_part3_241203.png

using UnityEngine;
using PowerEquipment;

namespace Device
{
    public class PrinterComponent : PowerConsumerComponent
    {
        [SerializeField] private int consumption = 500;
        public override Power Consumption => new(consumption);
    }
}
using UnityEngine;
using PowerEquipment;

namespace Device
{
    public class ShredderComponent : PowerConsumerComponent
    {
        [SerializeField] private int consumption = 320;
        public override Power Consumption => new(consumption);
    }
}

いずれもほぼ同等の実装で、消費電力のみが定義されています。本来はその機器が持つ独自の機能を実装していくのですが、本題とは逸れてしまうため割愛します。

この2つのコンポーネントスクリプトを実装したら、同じようにシーン上のGameObjectにアタッチしましょう。その後、改めてOutletComponentをInspectorで表示します。エディタUIにて空になっていたSocketFiledに対して、ドラッグ&ドロップで機器を指定できるようになりました。
スクリーンショット 2024-12-03 18.16.03.png

この設定のまま、再度Unityエディタを再生してみましょう。すると、参照している機器の消費電力に基づいて、壁面コンセントの消費電力が計算されてデバッグログに表示されました。
スクリーンショット 2024-12-03 18.17.21.png

ここで、現時点の概念設計も提示しておきます。

コンポーネントの順序性を保証する

コンポーネントとコンポーネントの参照関係が構築できましたが、1つ大きな問題を抱えています。最初に記載した通り、コンポーネントスクリプトはライフタイムサイクルに従いながらも、それぞれのコンポーネントは独立して動作します。つまり、それぞれ独立して更新処理がかかっているコンポーネント間に依存関係が発生する場合は、処理の前後関係を意図的に設定する必要があります。

今回のサンプル実装では問題とならないので割愛しますが、参照元よりも参照先となるコンポーネントが先に初期化されていることを保証するような仕組みの実装を推奨します。シンプルな実装例は、準備完了を示すフラグプロパティを組み込む方法です。参照先のフラグが立つまで、参照元の初期化を遅延させます。ただしいつまでも待つわけにはいかないので、タイムアウトを超えたらエラーを発行するような仕組みを構築するのが望ましいでしょう。

エディタUIをカスタマイズする

依存関係を含めたコンポーネントとモジュールの実装は、各機能の詳細な作り込みを除けば、ほぼ完了しました。しかし現状では、オペレーターにとって十分に使いやすい設計とはまだ言えません。最後はオペレータのために、コンポーネントの運用をより簡単にするエディタUIのカスタマイズを行います。

UnityにはエディタUIをカスタマイズするための仕組みが公式で提供されています。それらのスクリプトについて、先に概要を説明します。

Custom Editor

カスタムエディタは、コンポーネント本体のエディタUIを全面的に書き換えるためのスクリプトです。これはコンポーネントスクリプトと1対1で紐付けられ、特定のコンポーネントのために専用のUIを構築することができます。エディタUIをカスタマイズする際には非常に重要な役割を果たすもので、多くのエンジニアが利用している機能かと思います。

Porperty Drawer

一方で、プロパティドロワーはSerializableであるクラスに紐付けられます。カスタムクラスではUIが自動生成されなかったり、生成されてもレイアウトの自由度に制約が発生することがあります。プリミティブ型であるstringfloatであれば、Unityが適切なUIを自動生成してくれるため問題はありません。

このような制約を克服するために、特定のクラスを対象としたプロパティドロワーを実装することで、カスタマイズされた柔軟なUIを提供することが可能となります。

以上を踏まえた上で、エディタをカスタマイズするためのスクリプトを追加します。ついでに、OutletComponentPowerSocketFieldにもアクセサーとなるパラメータを追加してハードコードを回避しましょう。
UnityArchitecture_part4_241203.png

using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace PowerEquipment.Editor
{
    [CustomEditor(typeof(OutletComponent))]
    public class OutletComponentEditor : UnityEditor.Editor
    {
        public override VisualElement CreateInspectorGUI()
        {
            var root = new VisualElement();

            root.Add(CreateCapacityGUI());
            root.Add(CreatePowerSocketsGUI());

            return root;
        }

        private VisualElement CreateCapacityGUI()
        {
            var element = new VisualElement
            {
                style =
                {
                    flexDirection = FlexDirection.Row,
                }
            };

            var labelField = new Label
            {
                text = "最大消費電力 [W]",
                style =
                {
                    width = 120,
                    paddingTop = 1,
                }
            };

            var property = serializedObject.FindProperty(OutletComponent.CapacityFieldName);
            var capacityField = new PropertyField(property)
            {
                label = string.Empty,
                style =
                {
                    paddingRight = 3,
                    flexGrow = 1,
                    flexShrink = 1,
                }
            };

            element.Add(labelField);
            element.Add(capacityField);

            return element;
        }

        private VisualElement CreatePowerSocketsGUI()
        {
            var element = new VisualElement();

            var socketProperty = serializedObject.FindProperty(OutletComponent.SocketFieldsFieldName);
            var listView = new ListView
            {
                headerTitle = "接続機器",
                showFoldoutHeader = true,
                showBoundCollectionSize = false,
                showAddRemoveFooter = true,
                showBorder = true,

                bindingPath = OutletComponent.SocketFieldsFieldName,
                reorderable = true,
                reorderMode = ListViewReorderMode.Animated,

                makeItem = () => new PropertyField(),

                bindItem = (item, index) =>
                {
                    var property = socketProperty.GetArrayElementAtIndex(index);
                    (item as PropertyField)?.BindProperty(property);
                },
            };

            listView.Bind(serializedObject);

            element.Add(listView);

            return element;
        }
    }
}
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

namespace PowerEquipment.Editor
{
    [CustomPropertyDrawer(typeof(PowerSocketField))]
    public class PowerSocketFieldDrawer : PropertyDrawer
    {
        public override VisualElement CreatePropertyGUI(SerializedProperty property)
        {
            var element = new VisualElement
            {
                style =
                {
                    flexDirection = FlexDirection.Row,
                }
            };

            var consumerProperty = property.FindPropertyRelative(PowerSocketField.ConsumerComponentFieldName);

            var green = new Color(0.0f, 0.75f, 0.0f);
            var red = new Color(0.75f, 0.0f, 0.0f);

            var lamp = new VisualElement
            {
                style =
                {
                    width = 10,
                    height = 10,
                    marginTop = 4,
                    marginRight = 2,
                    borderBottomLeftRadius = new StyleLength(50),
                    borderBottomRightRadius = new StyleLength(50),
                    borderTopLeftRadius = new StyleLength(50),
                    borderTopRightRadius = new StyleLength(50),
                    backgroundColor = consumerProperty?.objectReferenceValue ? green : red,
                }
            };

            var consumerField = new PropertyField(consumerProperty)
            {
                label = string.Empty,
                style =
                {
                    paddingRight = 5,
                    flexGrow = 1,
                    flexShrink = 1,
                }
            };

            consumerField.RegisterValueChangeCallback(_ =>
            {
                lamp.style.backgroundColor = consumerProperty?.objectReferenceValue != null ? green : red;
            });

            element.Add(lamp);
            element.Add(consumerField);

            return element;
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace PowerEquipment
{
    public class OutletComponent : MonoBehaviour
    {
        [SerializeField] private int capacity = 1500;
        [SerializeField] private List<PowerSocketField> socketFields;

        private IPowerSupplier _supplier;

        private void OnEnable()
        {
            try
            {
                var sockets = socketFields
                    .Select(field => new PowerSocket(field.ConsumerComponent))
                    .Cast<IPowerSocket>()
                    .ToList();

                _supplier = new OutletModule(sockets, new Power(capacity));
            }
            catch (Exception e)
            {
                Debug.LogError($"モジュールの初期化に失敗しました: {e.Message}");
                return;
            }

            Debug.Log($"最大消費電力: {_supplier.Capacity.Watts} [W]");
            Debug.Log($"現在の消費電力: {_supplier.Consumption.Watts} [W]");
        }

        private void OnDisable()
        {
            _supplier = null;
        }
        // 追加
        // NOTE: CustomEditorおよびPropertyDrawerにおけるハードコード参照を回避する
#if UNITY_EDITOR
        public const string CapacityFieldName = nameof(capacity);
        public const string SocketFieldsFieldName = nameof(socketFields);
#endif
    }
}
using System;
using UnityEngine;

namespace PowerEquipment
{
    [Serializable]
    public class PowerSocketField
    {
        [SerializeField] private PowerConsumerComponent consumerComponent;
        
        public PowerConsumerComponent ConsumerComponent => consumerComponent;

        // 追加
        // NOTE: CustomEditorおよびPropertyDrawerにおけるハードコード参照を回避する
#if UNITY_EDITOR
        public const string ConsumerComponentFieldName = nameof(consumerComponent);
#endif
    }
}

これらの実装によりエディタUIの見た目は大きく変わります。今回はUIを詰めるような実装は行いませんが、この事例のようにカスタムエディタとプロパティドロワーを実装することで柔軟に見た目を調整できます。
スクリーンショット 2024-12-03 21.32.01.png

ここまでの実装で、基本構成は完成しました。以降は、同様の構造を意識しつつ、必要に応じて拡張を進めていけばよいでしょう。

(補足)テストコードを実装する

モジュールはUnityに依存しないため、設計の柔軟性が高く、SOLID原則やClean Architectureといった設計指針を適用しやすい特徴があります。一方、コンポーネントはUnity特有のライフタイムサイクルに依存しているため、制約があるものの、逆に言えばライフタイムサイクルに沿った実装が容易に行えるという利点があります。このように、提案した構成に従うことで、テストコードの実装もテンプレートのようにシンプルかつ効率的に行うことが可能です。

具体的には、モジュールはEditModeテストで検証し、コンポーネントはPlayModeテストを使用して動作確認を行うことができます。モジュールのテストは単体テストとして扱いやすいですが、コンポーネントのテストを統合テストとするか、単体テストとするかはプロジェクトの設計方針に依存するでしょう。

以上を踏まえた全体構成図を以下に提示します。この構成図は、冒頭で示した全体構造と一致しています。
UnityArchitecture_241203.png

おわりに

Unityにおけるコンポーネントスクリプトは、ビルドを前提としたゲーム制作に限らず、ツール開発や運用にも大変有用です。本記事では、シンプルなコンポーネント設計から始まり、エディタUIのカスタマイズや設計の考え方を通じて、運用に適したスクリプトの構築を紹介しました。

コンポーネントスクリプトは自由度が高い反面、気づけば複雑になりがちです。しかしながら、少しの工夫と整理された設計で、開発のしやすさや保守性を向上させることができます。この記事が、皆さんのプロジェクトに役立つヒントとなれば嬉しいです。

  1. 「なぜに壁面コンセント...?」と思われるかもしれませんが、もともと書こうと思っていた記事から計画変更したことに伴うものです。わかりにくいテーマですが気にしないでください。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?