Hero image

ゲームコードのイベントチャンネルとしてScriptableObjectを使用する

このウェブページは、お客様の便宜のために機械翻訳されたものです。翻訳されたコンテンツの正確性や信頼性は保証いたしかねます。翻訳されたコンテンツの正確性について疑問をお持ちの場合は、ウェブページの公式な英語版をご覧ください。

アプリケーション内の異種システムをどのように連携させるか?一般的な解決策のひとつは、イベントを使用してオブジェクト間でメッセージを送信することだ。UnityプロジェクトでScriptableObjectをイベントチャンネルとして使用する方法について説明します。

本書は、電子書籍に付属するデモでUnity開発者を支援するために作成された6つのミニガイドシリーズの第5弾です、 UnityでScriptableObjectsを使用してモジュラーゲームアーキテクチャを作成する.

このデモは、古典的なボールとパドルを使ったアーケード・ゲームのメカニズムにインスパイアされたもので、ScriptableObjectが、テスト可能でスケーラブル、かつデザイナーフレンドリーなコンポーネントの作成にどのように役立つかを示しています。

電子書籍、デモプロジェクト、そしてこれらのミニガイドを合わせると、UnityプロジェクトでScriptableObjectクラスを使用してプログラミングデザインパターンを使用するためのベストプラクティスが提供されます。これらのヒントは、コードを単純化し、メモリ使用量を減らし、コードの再利用性を促進するのに役立つ。

このシリーズには以下の記事が含まれる:

開始前の重要な注意

ScriptableObjectのデモ・プロジェクトやこの一連のミニ・ガイドに飛び込む前に、デザイン・パターンの核心は単なるアイデアに過ぎないということを覚えておいてほしい。すべての状況に当てはまるわけではない。これらのテクニックは、UnityとScriptableObjectの新しい使い方を学ぶのに役立ちます。

それぞれのパターンには長所と短所がある。特定のプロジェクトに有益なものだけを選ぶ。デザイナーはUnity Editorを多用していますか?ScriptableObjectベースのパターンは、開発者とのコラボレーションを助ける良い選択かもしれない。

結局のところ、最良のコード・アーキテクチャとは、あなたのプロジェクトとチームに合ったものなのだ。

疎結合、高結合力

アプリケーションでさまざまなモジュールやシステムを構築するとき、それらを "コードの島 "と考えると役に立つことが多い。各モジュールには、共通の目的のために一緒に働くいくつかのコンポーネントやGameObjectがあるかもしれません。

例えば、プレーヤーのパドルは、プレーヤーの入力を解釈するスクリプト、移動や衝突を処理するスクリプトなどで構成される。これらの部品に相互依存関係がある場合は、インスペクタを使って密接な関係を作ることができる。

しかし、依存関係を他のオブジェクトに追加するたびに、少なからずリスクが伴うことに留意してほしい。可能であれば、外部オブジェクトとの依存関係を最小限にしたい。モジュールやシステムの外部にあるものとのコミュニケーションは、それほど直接的なものではない。

パドル・スクリプトにゲーム内のボールを参照させることはできますが、それは両者が関連していることを意味します。いったん依存関係で結ばれてしまうと、片方に変更を加えると、もう片方にも影響を及ぼす可能性がある。

理想を言えば、他のものを壊さずにアプリケーションの一部を変更できるようにしたい。ゴールは、モジュールの内部的なまとまりを保ちつつ、外部からは切り離すことだ。

プロジェクトのNullRefCheckerクラスを使用すると、Inspector で必要な参照が欠落している場合に、丁寧な警告を出すことができます。各コンポーネントがセットアップまたは初期化された後、どこか(例えばAwake内)でstaticValidateメソッドを呼び出すだけでよい。

カスタムのOptional属性を追加して、フィールドが未設定のままでよい場合はチェックを無視するようにします。

イベントの利用

では、アプリケーション内のこれらの異種システムをどのように連携させるのか?

ひとつの解決策は、オブジェクト間でメッセージを送るためにイベントを使うことだ。イベントは、ブロードキャスターとリスナーのモデルに準拠している。

ここでは、リスニング・オブジェクトは、メソッドを呼び出したりプロパティを直接参照したりするのではなく、ブロードキャスター上のイベントを購読する。

あるコンポーネントに変更を加えても、他のコンポーネントへの影響は少ない。コードを修正しても、物事が壊れる可能性はあるが、オブジェクトが絡み合うことはほとんどない。真ん中のイベントは、その間の緩衝材のような役割を果たす。

私たちはしばしば、このブロードキャスターとリスナーの関係にあるオブジェクトを疎結合と表現する。
イベントとオブザーバー・パターンについては、当社のテクニカル電子書籍「ゲーム・プログラミング・パターンでコードをレベルアップ」で詳しく説明しています。

パブリッシャーイベント

集中イベントシステム

集中イベント

上記のシナリオでは、放送局は信号を送ることだけに責任がある。どのオブジェクトがリスニングしているかは気にしない。

しかし、OnEnableメソッドとOnDisableメソッドを使ってデリゲートにサブスクライブしたりアンサブスクライブしたりするためには、リスナーはまだブロードキャスターに関する知識を持っている必要がある。

イベントを静的クラスに移すことで、ブロードキャスターとリスナーをさらに切り離すことができる。一般的な "ゲーム・イベント "クラスは、この2つの間に抽象化のレイヤーを追加するのに役立つ。これによって、放送局とリスナーはお互いを直接知ることなくつながることができる。

この例では、簡単のために静的なGameEventsクラスを使う。しかし、実際の制作シナリオでは、UIEvents、GameStateEvents、HealthEvents、InventoryEventsなど、機能別に特化した小さなクラスに分けたほうがいい。

例えば、アプリケーションを終了したり、UI画面を表示したり、シーンをロードしたりするための静的イベントを作成することができる。これらのイベントを静的なものにすることで、アプリケーションのどの部分からでもアクセスできるようになる。

例えば、以下の例のようにGameEventsを作成する。

静的なGameEventは、ブロードキャスターとリスナーの中間に位置する。レシーバーとセンダーのどちらかに変更を加えても、もう一方に影響を与える可能性は低くなる。

その結果、コードを更新しても予期せぬ副作用が少なくなる。また、イベント定義を1つの場所に保存することで、管理も簡単になります。

静的なGameEventは効果的ですが、ゲームデザイナーにとってはあまり利用しやすいものではないかもしれません。これらは静的であるため、コード内で定義する必要があり、エディターではネイティブにシリアライズできません。

よりエディターフレンドリーなシステムには、ScriptableObjectをベースにしたイベントの実装を検討してください。

using UnityEngine;
using System;

public static class GameEvents
{
    public static Action ExitApplication;
    public static Action HomeScreenShown;
    public static Action<float> LoadProgressUpdated;
}
イベント

イベント・チャンネルは、放送局とリスナーの間で信号を中継する。

イベントチャンネルの設定

ScriptableObjectベースのイベントは、静的イベントに代わるグラフィカルなイベントを提供します。どちらも似たような機能を果たしますが、ScriptableObjectはInspectorに表示されるため、デザイナーに優しい傾向があります。

イベント・チャンネルは、放送局からリスナーへの信号を中継するもので、電波塔からの送信に似ている。

以下のScriptableObjectであれば、イベントチャネルとして機能する:

  • デリゲート(UnityActionやSystem.Actionなど):これは加入者に通知し、パラメータとしてデータを渡す。
  • イベントを盛り上げる手法:このパブリック・メソッドはデリゲートを呼び出します。

ゲームプレイのさまざまな側面を決定するために、いくつでもイベントチャンネルを設定できる。

UnityActionとSystem.Actionはどちらもデリゲートです。プロジェクトでは、どちらか一方、あるいは両方のタイプを使うことができる。

UnityActionは、よりアーティストフレンドリーな体験を生み出します。そうでない場合は、System.Actionデリゲートを使用してください。

以下に、プロジェクトのVoidEventChannelSOの例を示します。これはScriptableObjectベースのイベントで、パラメータは渡さない。

ここではOnEventRaisedというUnityActionを使い、publicなRaiseEventmethodを公開しています。

using UnityEngine;
using UnityEngine.Events;

[CreateAssetMenu(menuName = "Events/Void Event Channel", 
fileName = "VoidEventChannel")]
public class VoidEventChannelSO : DescriptionSO
{
    [Tooltip("The action to perform")]
    public UnityAction OnEventRaised;

    public void RaiseEvent()
    {
        if (OnEventRaised != null)
            OnEventRaised.Invoke();
    }
}
tab8

プロジェクトにイベントチャンネルを作成する。

イベントチャネルアセットの作成

プロジェクトにイベントチャネルアセットを作成して使用します。作成メニューを使用するか、既存のアセットを複製することができます。

各アセットの名前を変更し、説明フィールドを使用して各 ScriptableObject アセットを識別します。各イベントチャネルは、プロジェクトレベルのアセットとして存在することを忘れないでください。これらのアセットをMonoBehavioursで参照することになります。

これはオプションですが、ScriptableObjectベースのイベント・チャンネルに_SOサフィックスを付けて、データを運ぶ他のScriptableObject(これは_Dataサフィックスが付いています)と区別することができます。

フォルダと命名規則は、プロジェクトの整理整頓に役立ちます。あなたのプロジェクトのニーズに合わせてカスタマイズしてください。詳しくは「C#スタイルガイドを作成する」をお読みください。

tab9

インスペクタでイベントチャンネルを割り当てます。

イベント

シーン内のどのオブジェクトでもイベントチャンネルを参照できるようになり、RaiseEventメソッドを使ってイベントを呼び出すことができます。例えば、以下のTriggerEventメソッドを持つMonoBehaviourのサンプルを見てください。

Inspectorで、ScriptableObjectアセットをm_EventChannelフィールドに割り当てる必要があります。何かがTriggerEventを起動すると、イベントが実行される。聴いているものには通知が届く。

このメカニズムにより、ゲームアプリケーションにインタラクティブ性が加わります。各モジュールやシステムはイベントを発生させる(例えば、入力システムがキーを押したことを登録したり、ボールが壁に衝突したりなど)。その反応として、別の何かが反応する。

public class EventRaiser: MonoBehaviour
{
    [SerializeField]
    private VoidEventChannelSO m_EventChannel;

    public void TriggerEvent()
    {
        m_EventChannel.RaiseEvent();
    }
}
タブ

ゲームマネージャーは特定のイベントチャンネルを聞き、他のチャンネルでブロードキャストする。

イベントのためのリスニング

リスナーを設定するには、MonoBehaviourや他のコンポーネントがイベントチャネルのOnEventRaisedイベントをサブスクライブする必要があります。通常、これは以下の例のようにOnEnableで起こる。

イベント・チャネルがイベントを発生させると、それに応答してHandleEventメソッドが実行される。このメカニズムは、イベントのコンテキストに応じて、サウンドやエフェクトの再生、設定の変更など、さまざまな目的に使用できる。

PaddleBallSOプロジェクトでは、このようにメイン・ゲーム・ループを設定している。GameManagerはあるイベントチャンネルをリッスンし、別のイベントチャンネルにブロードキャストする。これにより、必ずしも直接の依存関係を持つことなく、異なるシステムが互いにメッセージを送り合うことができる。

最後に、エラーやメモリー・リークを防ぐために、OnDisableメソッドでOnEventRaisedイベントのサブスクライブを解除する。

public class EventListener: MonoBehaviour
{
    [SerializeField]
    private VoidEventChannelSO m_EventChannel;

    private void OnEnable()
    {
        m_EventChannel.OnEventRaised += HandleEvent;
    }

    private void OnDisable()
    {
        m_EventChannel.OnEventRaised -= HandleEvent;
    }

    private void HandleEvent()
    {
        Debug.Log("Event received");
    }
}
tab10

インスペクタに設定されたコードレス・インタラクティビティ

コードレス・リスナーの追加

もしあなたがデザイナーと仕事をしているのであれば、イベントをリッスンできるようにあらかじめ設定された汎用スクリプトをデザイナーに提供したいと思うかもしれません。これにより、プログラマーがいなくてもゲームのインタラクションを作成できるようになる。

VoidEventChannelListenerはこの例である。このコンポーネントは、イベントチャネルからシグナルを受信するとUnityEventを発生させます。VoidEventChannelListenerをGameObjectに追加し、イベントチャンネルとUnityEventロジックを設定するだけです。

設計者は、Inspectorでいくつかの設定をするだけで、イベント・ドリブン・ロジックのプロトタイプを作成できます。

例えば、GameOverSoundsプレハブはGameOver_SOイベントチャンネルをリッスンします。このイベントを受信すると、m_ResponseUnityEventを介して、指定されたAudioSourceでサウンドを再生します。

VoidEventChannelListenerクラスには、各レスポンスのタイミングを調整するための便利なディレイも含まれています。

少し練習すれば、これは異なるシステムやモジュール間の相互作用を構築する簡単な方法だ。

tab11

送受信用にマークされたイベント・チャンネル

イベント・チャンネルはどのように役立つか

プロジェクト・レベルで存在するため、イベント・チャンネルはグローバルにアクセス可能である。これにより、シーン階層内のどのオブジェクトとも接続でき、シーンロード中も持続する。

どんなオブジェクトでも、ブロードキャスターやリスナーとして振る舞うことができる。これにより、メッセージの送信に多くの柔軟性が生まれます。

注:チャンネルが送信用か受信用かをInspectorに明記するのは良い習慣だ。これにはHeaderAttributeを使う。

プロジェクト・レベルでイベントを使うことの副次的な利点は、シングルトンの必要性を代替できることが多いことだ。イベント・チャンネルはグローバルに利用できるため、あらゆるものをあらゆるものと結びつけることができる。カメラ、クエスト、ヘルス、アチーブメントなどのゲーム内システムを、不必要な依存関係を作ることなく動かすことができます。

さらに、イベントベースのアーキテクチャは必要なときだけ実行されるため、MonoBehaviourの更新メソッドよりも最適化されます。

ベースイベントの関数シグネチャ

このVoidEventChannelSOクラスは、パラメータを必要としないイベントに対してのみ機能する。多くの場合、提起されたイベントを意味のあるものにするためには、データの追加ペイロードが必要である。

例えば、ヘルスシステムでダメージを与えるイベントを送信する場合、ターゲットの値、送信するダメージの量、ダメージの種類などを渡したいかもしれない。

ベース・イベントの関数シグネチャを変更して、イベント・チャンネルをそれに適したものにすることができる。このプロジェクトでは、そのためのGenericEventChannelSOを定義している。下の例を見てほしい。

これはジェネリック・パラメーターを1つ持つ抽象クラスである。そこから他のイベントチャンネルを導き出すことになる。これらは、float、int、boolのような単一のパラメータを渡すことができる。

VoidEventChannelSOと同様に、GenericEventChannelSOはOnEventRaisedというUnityActionを備えています。しかし、今回はT型のパラメータを持つアクションである。

外部オブジェクトは、対応する public RaiseEvent メソッドを呼び出します。イベントにリスナーがいる場合は、指定されたパラメータを渡しながら実行される。

public abstract class GenericEventChannelSO<T>: DescriptionSO
{
    public UnityAction<T> OnEventRaised;

    public void RaiseEvent(T parameter)
    {
        if (OnEventRaised == null)
            return;

        OnEventRaised.Invoke(parameter);
    }
}

具体的なイベントチャンネルの作成

あとは、GenericEventChannelSOから具体的なイベント・チャンネルを派生させ、Tの値を埋めるだけだ。

通常のCreateAssetMenu属性を除けば、明示的な実装の詳細は必要ありません。

FloatEventChannelSOという浮動小数点数を扱うイベント・チャンネルを作るのは簡単だ。以下のコード例を見てほしい。

簡単なことだ!このワークフローを使用して、BoolEventChannelSO、IntEventChannelSOなどの追加を作成する。

ペイロードとして複数のパラメータが必要な場合は、必要に応じて追加のジェネリッククラス(GenericEventChannelSO<T,U>、GenericEventChannelSO<T,U,V>など)を定義する。

[CreateAssetMenu(menuName = "Events/Float EventChannel", fileName = "FloatEventChannel")]
public class FloatEventChannelSO : GenericEventChannelSO<float> {}
tab11

ボールがスコアゴールに当たったときの一連の流れ

すべてをまとめる

このアイデアは、アプリケーションをより小さく、よりモジュール化された部分に分割することだ。明確な境界線を設定することで、依存関係によって部品が絡み合うのを防ぎ、スパゲッティコードを防ぐことができる。

外部オブジェクトに関する直接的な知識を持たないコンポーネントは、想定外のものを操作することはできない。その代わり、イベント・チャンネルを通じてメッセージを送受信することを余儀なくされる。

この仕組みは、パドルボールのゲームプレイのシーケンスを少しなぞってみればわかる。例えば、ボールがScoreGoalに衝突したときのことを想像してみよう:

ScoreGoalコンポーネントは衝突を登録する。ボールを検出すると、GoalHit_SOイベントチャンネルにイベントを発生させる。これは得点プレーヤーのプレーヤーIDを渡す。

このイベントチャンネルはGameManagerに通知し、GameManagerはそれに応答してPointsScored_SOという別のイベントチャンネルを立ち上げる。これはプレーヤーIDも渡す。

このチャンネルはScoreManagerに通知し、ScoreManagerはスコア(別のオブジェクトに格納されている)を増加させ、UIコンポーネントを更新する。そして、ScoreManagerUpdated_SOイベントチャネルを介して、両方のプレーヤーのスコアを渡します。

その応答として、ScoreObjective_SOobjectiveは、1人のプレーヤーが目標得点に達したかどうかをチェックする。

勝利条件に達した場合、ゲームは終了する。そうでない場合、GameManagerはラウンドをリセットし、ボールはプレーに戻る。

一見すると、得点の値を1点増やすために多くの余分な作業があるように見えるかもしれない。しかし、その意図は関係するすべてのピースを切り離すことにある:ボール、ScoreManager、GameManager、ObjectiveManagerなど。

アプリケーションの各パーツには一定の自主性があり、そのおかげで各パーツのテストが容易になる。新しいシステムを追加しても、既存のロジックを混乱させる必要はない。実際、本来のゲームプレイでは、それらにまったく気づかないこともある。

採点プロセスに合わせて、サウンドやアニメーションなどの二次的な効果を加えたいと考えたとする。適切なイベントをリッスンし、適切に応答する新しいコンポーネントを作ることができる。新しいシステムを追加しても、基本的なロジックやゲームの流れは崩れることはない。

SOLIDプログラミングの信条は、"拡張はオープンに、変更はクローズに "であることを忘れないでほしい。既存のコードを変更することなく、ソフトウェアに新しい機能を追加したい。このようにイベント・チャンネルを使うことで、スケーラビリティが得られる。

検査官

エディタースクリプトはイベントのデバッグに役立ちます。

デバッグ・イベント

イベント駆動アーキテクチャは、デバッグとメンテナンスを容易にする。Unity Test Frameworkで自動化されたユニットテストを書く場合でも、非公式なトラブルシューティングを行う場合でも、小さなパーツの方がテストしやすい。これにより、特定の問題に焦点を当て、単独でテストすることができる。

カスタムエディタースクリプティングがこれを支援する。PaddleBallSOは、イベント・チャネルを使用する際にアプリケーションの流れをトレースするのに役立ついくつかのツールをデモしている:

  • PaddleBallSOプロジェクトのほとんどのイベントチャンネルは、インスペクタにリスナーのリストを表示します。各リスナーの名前をクリックすると、階層でハイライトされます。
  • カスタムRaiseEventボタンは、自由にモック・イベントを呼び出すことができます(ペイロードを運ぶ場合は、デフォルト値のTを使用します)。アプリケーションの実行中に、シングルクリックで手動でトリガーするだけです。

イベントチャンネルのトラブルシューティングを行う場合は、ScriptableObject アセットを選択します。必要に応じてイベントを手動でテストする。インスペクターは、盗聴している可能性のあるものを案内してくれる。さらに詳しく検査したいリスナーを選択します。

HeaderAttrituteでイベントチャンネルにラベルを付けておけば、いくつかのイベントを辿ってロジックの流れを理解することができる。

スクリプト可能なアウトロ

その他のScriptableObjectリソース

イベント・チャンネルとイベント・ドリブン・アーキテクチャーが、あなたの新しいプロジェクトやこれからのプロジェクトに役立つことを願っています。

ScriptableObjectsを使ったデザインパターンについては、テクニカル電子ブックをご覧ください、 UnityでScriptableObjectsを使用してモジュラーゲームアーキテクチャを作成する.また、一般的なUnity開発のデザインパターンについては、以下をご覧ください。 ゲームプログラミングパターンでコードをレベルアップ.

このコンテンツにご満足いただけましたか?