Unreal Editor 操作 MCP サーバーの学習用の小さなサンプルを作った

alt text

作成した MCP サーバー

Study_UnrealEditorMCP という名前で以下に配置してあります。

GitHub - self-taught-code-tokushima/Study_UnrealEditorMCP

現状はできることは ping というツールのみで、Unreal Editor と接続できることを確認することしかできません。

使い方

この MCPUnreal Editor Plugin と、PythonMCP サーバーで構成されています。

Claude Desktop/VS Code/Claude Code
    ↓ (MCP プロトコル)
Python MCP Server (study-unrealeditormcp)
    ↓ (HTTP REST API)
Unreal Editor Plugin (UnrealEditorMCP)
    ↓
Unreal Engine 5 Editor

Unreal Editor Plugin

自分のプロジェクトにインストールする場合でなければ、リポジトリにある SampleProject は最初からこのプラグインを認識しているため、起動するだけで OK です。

自分のプロジェクトにインストールする場合

リポジトリルートにある Plugins/UnrealEditorMCP フォルダを自身のプロジェクトの Plugins に入れてください。

PythonMCP サーバー

この MCP サーバーは stdio タイプで実装されているため、例えば Claude Desktop / Claude Code であれば以下の設定で利用できます。

ただ、以下のサンプルはいずれも uv がローカルにインストールされている前提です。

Claude Desktop

Claude Desktop の設定ファイルに以下のような設定を追加します。

%APPDATA%\Roaming\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "study-ue_editor_mcp": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/self-taught-code-tokushima/Study_UnrealEditorMCP", "study-unrealeditormcp"],
      "env": {}
    },

Claude Code

claude mcp add -s project --transport stdio study-ue_editor_mcp -- uvx --from git+https://github.com/self-taught-code-tokushima/Study_UnrealEditorMCP study-unrealeditormcp

出力例

Claude Code の例です。

❯ study-ue_editor_mcp MCP のステータスを確認して

● study-ue_editor_mcp
  MCPのステータスを確認します。まず、pingツールをロードしてから実行します。

● MCPSearch(Search MCP tools: "select:mcp__study-ue_editor_mcp__ping")
  ⎿  Found 1 tool

● pingツールがロードされました。これでUnreal Engineとの接続状態を確認します。
  ⎿  {
       "result": {
         "success": true,
     … +3 lines (ctrl+o to expand)

● study-ue_editor_mcp MCPサーバーは正常に動作しています。

  ステータス確認結果:
  - 接続状態: 成功 ✓
  - 応答メッセージ: "pong"

  Unreal Engineとの接続が確立されており、MCPサーバーは正常に稼働しています。

学習用のサンプルとしての MCP サーバー

上記の通り、この MCP サーバーには実質 Unreal Editor を操作する機能はありません。
また、多くの LLM との連携機能を求めるのであれば、すでに多くの Unreal Editor 用の MCP サーバーやプラグインが有償・無償で多く出ていますのでそちらを使えばいいでしょう。

つまり、この MCP サーバーは学生が Unreal Editor 用の MCP サーバーの仕組みを知るための初歩のサンプルになっています。

構成

他の Unreal Editor の MCP サーバーの構成例

Unreal Editor の Plugin との連携で動く MCP サーバーは 2025 年に多く発表されました。いくつかパターンがあります。

まず、Unreal Editor 側から操作用の API を公開するのは以下のようなものが見られます。

  • Unreal Editor Plugin 側は Socket を使って API を公開
  • Unreal Editor Plugin 側は HTTPServerModule を使って API を公開し
  • Unreal の RemoteControl API 使う
    • できることは制限される

さらに多くの場合は、その前段に Claude や VS Code などにツールを公開するための MCP サーバーを Python や Node で作成し配置しています。一部、直接 CLI 系の Agent を実行するという ACP (Agent Client Protocol) のような使い方をしているケースもありました。

もちろん、Unreal Editor の Plugin として直接 SSE や HTTP の MCP サーバーとして公開することもできると思いますが、現状はライブラリもありませんし、そのせいもあって、作りやすい Python や Node の MCP サーバーを前段に置いているのだと思います。私は有償のプラグインは購入していないので、他のパターンがある可能性は十分にあるとおもいます。

Unity では UnityNaturalMCP は直接接続できる MCP サーバーを Unity の Plugin として実装しています。

今回採用した構成

Claude Desktop/VS Code/Claude Code
    ↓ (MCP プロトコル)
Python MCP Server (study-unrealeditormcp)
    ↓ (HTTP REST API)
Unreal Editor Plugin (UnrealEditorMCP)
    ↓
Unreal Engine 5 Editor

再掲になりますが、上記のような構成になっています。

  • Unreal Editor Plugin は HTTPServerModule を使って HTTP の API を用意
    • SSE はサポートしていないため、毎回のリクエスト・レスポンスにはなるが、初学者にもわかりやすい
  • MCP サーバーは既存の実装の例も多い Pythonmcp パッケージを使って実装
    • 不明点が合った場合に、検索するにしろ、AI に質問するにしろ回答が得られやすい
  • Claude Desktop 等から MCP サーバーへの接続は stdio 方式
    • 複数接続等は考慮しないが、Claude Desktop からの接続も mcp-remote 等もいらないため失敗しにくい

できるだけ単純な構成にしたつもりではありますが、それでも JSON の扱い等はやはり行数が多くなってしまい、ここは私の Unreal C++ 力が足りていません。

学生にどう使ってもらいたいか

MCP サーバーを使う」というのは、Unity や UE5 を学習する学生の中でも AI を活用していると自然と目に入ってくるようです。

ですが、Python や Node を別途 PC にインストールするのも慣れていないと大変ですし、できたとしても「MCP サーバーがうまく動作しない」という状況になると諦めてしまうことも多いようです。

Unity や UE5 のようなゲームエンジンは、ログもエディタ内に表示してくれますが、AI 系のアプリは AppData の奥にログファイルや設定ファイルがあったりで余計に分かりにくく足が遠のいてしまいます。

そういう人に、シンプルな構成の MCP サーバーを使って動作を試してもらいたいと思います。
仮に動作に失敗してもコードベースが小さいので、他の本格的な MCP サーバーよりは怖がらずに見れるのではないかと思います。

JetBrains Rider で ACP (Agent Client Protocol) を利用して Claude Code を使う

JetBrains IDE の ACP 対応

blog.jetbrains.com

上記のブログ記事にあるように JetBrains IDE が ACP に対応したようです。

ACP に関してはすでに多くの解説がありますが、Claude Code や Gemini CLI、Codex CLI のようなエージェントを IDE に持ち込めるというものです。 Introduction - Agent Client Protocol

これだけだと、「すでに GitHub Copilot や JetBrains AI にも Agent モードがあるのでは?」と思うかもしれませんが、そこで Claude Sonnet のモデルを使っていてもあくまで GitHub Copilot 等が提供する Agent 機能であって、Claude Code の提供する Agent 機能とは異なります。

JetBrains IDE のその他の機能

一方で JetBrains IDE は他にも機能も提供しています。

特に Claude の場合は Claude Agent 機能があります。どうやら Sonnet しか利用できないようですが、JetBrains AI のサブスクリプションの範囲で利用できるので、そちらを契約している場合はそのまま利用できていいですね。

Claude Code Plugin は Anthropic が提供するプラグインですが、ターミナルで Claude Code を利用しつつ、差分の確認やコンテクストの追加を IDE と連携できます。

BYOK (Bring Your Own Key) も提供されており、Claude 等をサブスクリプションではなく API キーを利用している場合は JetBrains AI に追加で契約する必要がなくていいですね。

どうして ACP を利用する?

UE5 や Unity の開発を学生が行う場合においてもやはり 「AI のサポートは無し」というのは考えられないです。

その場合、GitHub でのソース管理をしつつ、Visual Studio や JetBrains IDE を使うとなると最初に候補にあがるのは GitHub Copilot です。ある程度無料の範囲もあるので試しやすく、私もまずはそこから始めてもらっています。

一方で、保護者に一番認知があり、すでに契約している可能性が高いのは OpenAI の ChatGPT です。
今だと CM も多く放送されているため Gemini も認知度が高いかもしれません。
また、Claude や開発者の人気も依然として高く、その分ノウハウも多く Web にあり候補にあがってきます。

ただ、学生に「これもこれも」と複数のサブスクリプションを契約させるわけにはいきません。

また、Claude Code や Codex/Gemini CLI のようなターミナルタイプは、GitHub Copilot と違ってとっつきにくく、できれば UE5 や Unity で利用する IDE の中から使えることが望ましいです。

ACP は実態としては CLI ツールを裏側で動かしているだけなので、各社のサブスクリプションをそのまま利用できます。つまり、JetBrains AI のチャット UI を使いつつ Claude Code 等の機能を使うことができます。

さらに Claude Code のサブスクリプションも契約しているため、そのままターミナルで利用し Skills やサブエージェントの機能等も利用してもいいわけです。

もちろん、前述のように Claude Agent が JetBrains IDE には機能としてあるので、JetBrains AI のサブスクリプションを契約して利用するということもありだと思います。(ただ、Claude Agent 機能は Claud Agent SDK 経由で利用されているので、直接ターミナルから利用するには別途 API キーか Claude のサブスクリプションが必要になるはずです。)

サブスクリプションが使えることの参考資料

JetBrains Rider で ACP を利用して Claude Code を利用する

ここから本題です。

  • OS: Windows 11 Home
  • Rider: 2025.3.1
  • Claude Code: v2.0.76

Claude Code の導入

これに関してはいくらでも Web に資料がありますが、基本的には以下の公式の方法で問題ありません。

クイックスタート - Claude Code Docs

Windows の場合は Powershell を開いて irm https://claude.ai/install.ps1 | iex をペーストするだけですね。 (Git for Windows の導入もお忘れなく)

導入できたら、Unity や UE5 のプロジェクトのフォルダで一度 claude コマンドを実行しておくと安心ですね。

失敗するケース

私が上記の方法で試した際には失敗してしまいました。原因は分かりませんが以下で議論されている内容で対処しました。

[BUG] Native Windows(Powershell) install of Claude Code is not viable · Issue #14902 · anthropics/claude-code · GitHub

もし、Node.js の利用に慣れているなら npm 経由で導入する方法を試すほうが簡単かもしれません。

Rider の ACP の設定を行う

Rider の AI Chat ウィンドウを開きます。

オプションメニューの中に 「Configure ACP Agent」があるので選択します。

そうすると、acp.json が開きます。このファイルは実際には %USERPROFILE%\.jetbrains\acp.json にあるファイルです。つまり、プロジェクトの範囲ではなく、エディタの設定になるようですね。

acp.json の設定

acp.json の設定については JetBrains のサイト及び、ACP の公式サイトを参照します。

加えて、Claude Code は ACP にネイティブでは対応していないので、zed から提供されているアダプタを利用します。

今回は以下のように Claude Code2 という名前で設定しています。Claude Agent もいるので、間違いなく ACP で利用しているものだと分かるようにしているだけで名前は自由です。

{
    "agent_servers": {
        "Claude Code2": {
            "command": "D:\\%npxまでのフルパス%\\npx.cmd",
            "args": [
                "@zed-industries/claude-code-acp"
            ],
            "env": {}
        }
    }
}

command にはフルパスで記述していますが、ここは皆さんの環境で変更してください。
私は npx で直接 claude-code-acp を実行していますが、zed のサイトにあるように一度 npm install で導入してから使ってもよいと思います。

ここまで設定すると以下のように AI Chat に追加されます。もし追加されない場合は IDE を再起動してみてください。それでも現れない場合は書き方が誤っているのかもしれません。

使用感

許可のダイアログが出る 変更ファイルへのジャンプ 変更差分の表示 エディタのファイルをコンテクストに追加

上記にスクリーンショットをいくつか貼りましたが、基本的なことは問題なくできています。

使用量を確認すると消費されているので、間違いなくサブスクリプションが利用されているようです。

サポートしている機能

claude-code-acp のサイトを見ると、現在サポートしている機能は以下のようなものです。

  • Context @-mentions
  • Images
  • Tool calls (with permission requests)
  • Following
  • Edit review
  • TODO lists
  • Interactive (and background) terminals
  • Custom Slash commands
  • Client MCP servers

この辺りは ACP の方でも策定されているようですね。

最後に

今回は JetBrains Rider での ACP を利用した Claude Code の利用を試しました。
Windows で行うともう少し面倒かと思いましたが、思ったよりも簡単にできましたし安定して動くようです。

私自身は昔から JetBrains どっぷりなのですが、学生の皆さんに使ってもらう分には世の中で多く使われていて情報が得やすいものの方がいいと思っています。

今回は ACP を試しましたが、Claude Code に限れば IDE 連携機能は Visual Studio Code 系や JetBrains IDE には提供されており、今回の ACP とほぼ同じような体験は可能だと思います。(他の CLI 系はあまり調査できておらず)

今後も気軽に試せる選択肢が増えるといいなと思います。

参考資料

Unity ScriptableObject のデザインパターン「イベントチャンネル」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「拡張可能な Enum」を確認する - Self-Taught CODE Tokushima Tech Blog

前回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「拡張可能 Enum」の内容を確認しました。全体として以下の 5 つがあり、今回は最後の「イベントチャンネル」です。

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

イベントチャンネル

Use ScriptableObjects as Event Channels in Your Code | Unity

緩やかな結合と高い凝集度

  • Unity では、「移動・衝突判定」などを処理するコンポーネントの相互依存を、Inspector で簡単に連携できる
  • 一方で、他のオブジェクトへの依存関係が増えると一定のリスクが生じる
    • よって、依存関係は最小限に抑えることが望ましい
    • システム外とのやり取りのように、直接的ではない方法がよい
    • ⚠️ ここでいう「一定のリスク」とは、依存を持つコードの変更をすることで、依存している・されている側のコードに直接的に影響がでること(例えばビルドエラーとか)
    • 変更する以上は、依存関係を抑えていてもゲームとしての影響は出るが、いきなり壊れたりしないように影響を小さくしましょうねということ
  • 目標は、モジュール内部では緊密に結合しつつ、外部との依存関係は可能な限り分離する
    • 🤔 このモジュールという言い方は、割と突然使われたりして日本語で理解しにくい。例えば Paddle Ball ゲームでは「パドル」と「ボール」は違うモジュールとして扱うが、場合によっては同じモジュールとして扱うこともある。
    • あくまで管理する範囲をどう決めるかということなので、定義は考えない方がいい。
  • Paddle Ball プロジェクトの NullRefChecker クラスを使うと、Inspector で参照が欠落している場合に警告を表示できる
    • Awake などで Validate を呼び出す
    • ✅ これは多くの開発の場面で依存をチェックする方法として使われる
  • フィールドを未設定にしても問題ない場合は、カスタム属性 [Optional] を追加することでチェックを無視できる

NullRefChecker

NullRefChecker は以下のような実装になっており、利用方法としては NullRefChecker.Validate(this); として MonoBehaviour 自身を渡してしまえばよいです。

GetType, GetFields と普段は使わないメソッドが出ていますが、これはリフレクション系の API でクラス自体を調査するようなものです。つまり、ここでは渡された MonoBehaviour のフィールドのうち、「インスタンス変数、public, public 以外」を満たすものを集めています。

そのフィールド群を「[Serializable] 属性がなければスキップ」「[Optional] 属性があればスキップ」し、値が未設定のものがあればログに出しています。

public static class NullRefChecker
{
    public static void Validate(object instance)
    {
        FieldInfo[] fields = instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);

        foreach (FieldInfo field in fields)
        {
            // [Serializable] 属性がなければスキップ
            // [Optional] 属性があればスキップ
            if (!field.IsDefined(typeof(SerializeField), true) || field.IsDefined(typeof(OptionalAttribute), true))
            {
                continue;
            }

            // If null, log an error
            if (field.GetValue(instance) == null)
            {
                // Check if instance is a MonoBehaviour...
                if (instance is MonoBehaviour monoBehaviour)
                {
                    GameObject gameObject = monoBehaviour.gameObject;

                    Debug.LogError($"Missing assignment for field: {field.Name} in component: {instance.GetType().Name} on GameObject: " +
                        $"{monoBehaviour.gameObject}", monoBehaviour.gameObject);
                }
                // ... or a ScriptableObect
                else if (instance is ScriptableObject scriptableObject)
                {
                    Debug.LogError($"Missing assignment for field: {field.Name} on ScriptableObject:  " +
                        $"{scriptableObject.name} ({instance.GetType().Name})");
                }
                else
                {
                    Debug.LogError($"Missing assignment for field: {field.Name} in object: {instance.GetType().Name}");
                }

            }
        }
    }
}

カスタム属性 [Optional]

こちらは空の実装ですね。あくまで NullRefChecker での検査を回避するためのもののようです。

public class OptionalAttribute : PropertyAttribute
{
}

イベントの使用方法

  • では、アプリケーション内でモジュール群をどのように連携させるか
  • イベントを使用してオブジェクト間でメッセージを送受信する

集中型イベントシステム

  • 上記の Broadcaster -Listner では、Broadcaster (または Publisher or 送信者) は誰が受信するかについては関心をもたない
  • 逆に、Listner 側は、イベントの登録解除を行うため Broadcaster の存在についてある程度の知識が必要にある

以下のような実装を考えると、BroadcasterListner のことを知らないが、ListnerBoardcaster のことを知っている状態になります。

public class Broadcaster : MonoBehaviour {
    public UnityAction<Vector2> RaiseEvent;

    private void OnCollisionEnter2D(Collision2D collision) {
        RaiseEvent.Invoke(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    [SerializeField]
    private Broadcaster boardcaster;

    private void OnEnable() {
        boardcaster.RaiseEvent += ListenMothod
    }

    private void OnDisable() {
        boardcaster.RaiseEvent -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

ここでは、その対策として以下の GameEvents のような静的クラスの導入を提案しています。

public static class GameEvents
{
    public static Action ExitApplication;
    public static Action HomeScreenShown;
    public static Action<float> LoadProgressUpdated;
}

このパターンを使って書き直すと以下のように書き直せます。

public static class GameEvents {
    public static Action<Vector2> OnCollision;
}

public class Broadcaster : MonoBehaviour {
    private void OnCollisionEnter2D(Collision2D collision) {
        GameEvents.OnCollision(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    private void OnEnable() {
        GameEvents.OnCollision += ListenMothod
    }

    private void OnDisable() {
        GameEvents.OnCollision -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

確かに中間に位置する仲介役として機能する一方で、GameEvents は静的クラスであるため Inspector での表示ができず、エディタフレンドリーとは言えません。

イベントチャネルの設定

上記を解決するのが、ScriptableObject ベースのイベントです。ScriptableObjectシリアライズ可能なので Inspector に表示することができ、GameEvents よりも仲介役としてエディタフレンドリーです。

alt text

イベントチャンネルとして機能させるには以下を持つことが条件になります。

  • デリゲート (UnityAction or System.Action など)
    • Subscriber (Listner 受信側) に通知を行い、データをパラメータとして渡す
  • イベント発生メソッド
    • この public メソッドがデリゲートを実行する
public class Vector2EventChannelSO {
    public UnityAction<Vector2> OnEventRaised;

    public void RaiseEvent(Vector2 vec2) {
        OnEventRaised.Invoke(vec2);
    }
}

public class Broadcaster : MonoBehaviour {
    [SerializeField]
    private Vector2EventChannelSO onCollision;

    private void OnCollisionEnter2D(Collision2D collision) {
        onCollision.RaiseEvent(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    [SerializeField]
    private Vector2EventChannelSO onCollision;

    private void OnEnable() {
        onCollision.OnEventRaised += ListenMothod
    }

    private void OnDisable() {
        onCollision.OnEventRaised -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

上記のように書き直すことで、Vector2EventChannelSO のアセットを作成し、Inspector から Broadcaster/Listner のそれぞれに割り当てることができます。

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

先ほど、「Vector2EventChannelSO のアセットを作成し」と書きましたが、このアセットも Unity エディタから作成できます。

また、命名に関してもサフィックスのガイドがでています。

  • イベントチャンネル用
    • _SO
    • BallCollided_SO
    • 🤔 そもそもScriptableObjectスクリプトVector2EventChannelSO のように SO が最後に付くので、アンダーバーだけというのはちょっと分かりにくい気もするが、ScriptableObject のアセットは見た目も違うので、データと識別できれば十分か
  • データコンテナ用
    • _Data
    • DefaultGame_Data

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

  • イベントチャンネルはプロジェクトレベルで存在するためグローバルにアクセス可能
    • シーン階層内の任意のオブジェクトを接続できる
    • シーンの読み込み後も状態を保持できる
  • 任意のイベントチャンネルオブジェクト (ScriptableObject のアセット) が Broadcaster または Listner として機能できる
    • ⚠️ Inspector では、どちらの用途で使われているかを HeaderAttribute で明示するべき
  • プロジェクトレベルでのイベント利用の利点は、シングルトンパターンの必要性を多くの場合置き換えられること
    • 先程の GameEvents もシングルトンパターンではなかったものの、実質グローバルに依存を及ぼしていた

イベントのデバッグ

alt text

上記のような Raise Event ボタンで任意のタイミングでイベントを発生させられるようにするとデバッグがしやすくなる。

これに関しては実際にコードを見てもらった方が早いのでリンクをしておきます。

https://github.com/UnityTechnologies/PaddleGameSO/blob/main/Assets/Core/EventChannels/Editor/GenericEventChannelSOEditor.cs

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「イベントチャンネル」を確認しました。

eBooks よりもイベントチャンネルがどうして有用なのかが段階的に説明されていて理解が深まりました。

カスタムのエディタスクリプトや、Inspector の表示をどのように整理すべきか、イベントチャンネルをどの程度作成するかなど、Paddle Ball プロジェクトの内容は実践的な実例なので、参考にしていきたいなと思います。

Unity ScriptableObject のデザインパターン「拡張可能な Enum」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「ランタイムセット」を確認する - Self-Taught CODE Tokushima Tech Blog

ここまでの記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「ランタイムセット」の内容を確認しました。全体として以下の 5 つがあり、今回は 4 つ目の「拡張可能な Enum」です。

イベントチャンネルはかなり長いので、後回しにします。

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

拡張可能な Enum

  • enum はコード内で固定された名前付きの値を管理する方法
    • シリアライズされるとシンボル名ではなく整数として保存される
    • 明示的に数値を指定しないと、値を削除したり順序変更すると予期しない結果がでる
[System.Serializable]
public enum HandGestures
{
    Rock,
    Paper,
    Scissors
}

System.Serializable 属性をつけて列挙型をシリアライズすると Inspector に表示されるようになる。

ただ、上記は暗黙的に Rock = 0, Paper = 1, Scissors = 2 の番号がついており、これに依存すると、Paper を消した時に、Scissors の値が 1 になってしまう。

🤔 個人的には、数値に依存することはほぼ無いと思うので、この問題は気にしなくていいと思うが Unity 開発では問題になるのでしょうか。

ScriptableObjectベースの列挙型

ScriptableObjectベースの列挙型は、従来の列挙型と同様の機能を持ちながら、個別のアセットとして保存されます。

[CreateAssetMenu(fileName = "PlayerID")]
public class PlayerIDSO: ScriptableObject
{
}

上記は Paddle Ball ゲームの PlayerIDSO ですが、見た目は空の ScriptableObject です。

ですが、先程の C#enum と同様に、PlayerIDSO から作られたアセットはデータが格納されていなくとも「異なる値」として比較可能になります。

等価性 の確認に使えるだけで、この空の ScriptableObject は価値があります。

PaddleBallSO におけるプレイヤーID

Paddle Ball ゲームでは前述の通りプレイヤーを PlayerIDSOP1, P2 としてそれぞれアセットを作成し識別していました。

パターンデモ

alt text

このデモでは、TeamID という仕組みが入っています。チームといってもこのデモにおいては「ブロックの種類」がチームになっています。

上図のように赤で囲まれた「枠だけのブロック」と青で囲まれた「塗りつぶされたブロック」がそれぞれチームに分かれています。

P1 青チーム P2 赤チーム
alt text alt text

それぞれの Team ID コンポーネントP1_SOP2_SO がついているのが分かります。

加えて、Destroy On CollisionUse Team ID というチェックがついているのが分かります。

ではそれぞれを見ていきます。

TeamID

public class TeamID : MonoBehaviour
{
    [Tooltip("ScriptableObject for comparison")]
    [SerializeField] private PlayerIDSO m_ID;

    public PlayerIDSO ID { get => m_ID; set => m_ID = value; }

    public bool IsEqual(PlayerIDSO id)
    {
        return id == m_ID;
    }

    public static PlayerIDSO GetTeam(GameObject gameObject)
    {
        return gameObject.GetComponent<TeamID>().ID;
    }

    public static bool AreEqual(GameObject a, GameObject b)
    {
        TeamID teamA = a.GetComponent<TeamID>();
        TeamID teamB = b.GetComponent<TeamID>();

        if (teamA != null && teamB != null)
            return teamA.ID == teamB.ID;

        return false;
    }

    public static bool AreBothNull(GameObject a, GameObject b)
    {
        TeamID teamA = a.GetComponent<TeamID>();
        TeamID teamB = b.GetComponent<TeamID>();

        if (teamA == null && teamB == null)
            return true;

        return false;
    }
}

IsEqual のように PlayerIDSO を直接比較するか、AreEqual のように、それぞれから TeamID コンポーネントを取り出して、そこから PlayerIDSOID を比較する違いはあるものの、いずれも PlayerIDSO の等価性を使って比較しています。

DestroyOnCollision

続いて、DestroyOnCollision です。

public class DestroyOnCollision : MonoBehaviour
{
    [Tooltip("Only destroy when colliding with objects from this PlayerID")]
    [SerializeField] private bool m_UseTeamID;

    [Header("Broadcast to Event Channels")]
    [SerializeField] private GameObjectEventChannelSO m_DestroyGameObject;

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.collider.GetComponent<Ball>() == null)
            return;

        if (m_UseTeamID)
        {
            PlayerIDSO id = TeamID.GetTeam(this.gameObject);

            if (!TeamID.AreEqual(collision.collider.gameObject, this.gameObject))
            { 
                return;
            }

            if (TeamID.AreBothNull(collision.collider.gameObject, this.gameObject))
            {
                return;
            }
        }

        m_DestroyGameObject.RaiseEvent(gameObject);
        Destroy(gameObject);

    }
}

m_UseTeamID にチェックが入っていると、自身(つまりブロック)のチームID を読み出し、衝突したもの(つまりボール)のチームID と比較して、等価であれば削除する という処理になります。

もちろん、以下のようにボール (DemoBall) にも TeamID がついています。つまり、ボールのチームIDと一致するブロックしか破壊できないということですね。

alt text

このデモでは、ボタンによって、ボールのチームIDを切り替えられるようになっていて、それだけでボールの振る舞いが変わるというのも面白いですね。

動作の拡張

  • ScriptableObject は、enum とは異なり、追加のデータの保持や、フィールド、メソッド定義も可能
    • 専用の比較ロジックを実装すれば、特殊なダメージ効果を定義することもできる
  • インベントリシステムのためのアイテム種別や武器スロットの表現にも適している
    • 特定のキャラクターのみ装備可能
    • アイテムに魔法効果や特殊能力の付与
  • Weakness(弱点) のフィールドを持たせて、専用の判定メソッド(例えば IsWinner のような) をつければ、ジャンケンのような複雑な相互作用も表現できる

ScriptableObjectベースの列挙型を使用するメリット

  • ScriptableObject ベースの列挙型を使うと、特にチーム開発での作業効率が上がる
    • アセットとして扱われるため、更新時のマージ競合が発生しにくく、データ損失のリスクも低減
  • エディタでのドラッグ&ドロップUIを使うことで、デザイナーでもゲームデータの拡張を行える
    • フィールドの初期設定は調整が必要だが、使い始めればデザイナー自身がデータの入力作業を行える

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「拡張可能な Enum」を確認しました。

C#enum を使っていても拡張メソッドや属性で、追加のパラメータやメソッドを追加することがあります。

ScriptableObject は class なので、enum よりは重いですが、それでも Inspector からも使えることを考えるととても有効な方法のように思えます。う

Unity ScriptableObject のデザインパターン「ランタイムセット」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「デリゲート」を確認する - Self-Taught CODE Tokushima Tech Blog

前回、前々回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「デリゲート」の内容を確認しました。全体として以下の 5 つがあり、今回は 3 つ目のランタイムセットです。

イベントチャンネルはかなり長いので、後回しにします。

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

ランタイムセット (RUNTIME SETS)

このランタイムセットは eBooks では紹介がありましたが、Paddle Ball のゲーム部分には使われておらず、最初の記事で紹介していません。

  • ScriptableObject は静的データを格納できるほか、動的データを保持するようにカスタマイズもできる
  • これは、「シングルトン (Singleton)」の利点の一つである グローバルなアクセスの容易さ を再現している
  • しかし、シングルトンは不要な依存関係を生じてしまう
  • その代替手段として、「ランタイムセット」を検討できる
    • ゲーム要素の追跡に活用できる
  • ScriptableObject はプロジェクトレベルのアセットであり、シーン内のすべてのオブジェクトからグローバルにアクセス可能
    • シングルトン使用時にありがちな欠点をほとんど伴わずに情報を共有できる

この部分だけだと、シングルトンの欠点を伴わない理由がいまいち理解できませんが、まずは「シングルトンを検討する時にランタイムセットを思い出す」「特にゲーム要素の追跡に使いたい場合」を頭に入れたいと思います。

基本的な実装方法

まず、以下のコード例が示されていました。かなりシンプルな GameObject のリストの管理クラスです。
ドキュメントでも「データコンテナのようなもの」とありますが、動的に要素を変更できるデータコンテナのように見えます。

確かにシングルトンでも実装しそうなものです。

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "GameObject Runtime Set", fileName
= "GORuntimeSet")]
public class GameObjectRuntimeSetSO: ScriptableObject
{
   private List<GameObject> items = new List<GameObject>();
   public List<GameObject> Items => items;

   public void Add(GameObject thingToAdd)
   {
       if (!items.Contains(thingToAdd))
            items.Add(thingToAdd);
   }

   public void Remove(GameObject thingToRemove)
   {
       if (items.Contains(thingToRemove))
            items.Remove(thingToRemove);
   } 
}

Generic バージョン

続いて Generic を用いて実装されたバージョンです。そのままですね。(Items が公開フィールドになっているのは記事のボリューム都合の簡略化でしょうか。)

public abstract class RuntimeSetSO<T>: ScriptableObject
{
   public List<T> Items = new List<T>();

   public void Add(T thing)
   {
       if (!Items.Contains(thing))
            Items.Add(thing);
   }

   public void Remove(T thing)
   {
       if (Items.Contains(thing))
            Items.Remove(thing);
   } 
}

[CreateAssetMenu(menuName = "Enemy Runtime Set", fileName =
"EnemyRuntimeSet")]
public class EnemyRuntimeSetSO: RuntimeSet<Enemy>
{

}

使用方法と制限事項

  • ランタイムセットの使用方法は開発者の裁量次第
  • 多くの場合は、ゲームプレイ中に設定や更新をするために、別の MonoBehaviour を使用する
    • 例えば、自動的にセットしたいなら、OnEnable/OnDisable でランタイムセットの Add/Remove を呼ぶなど
    • Prefab として以下の Enemy を利用するようなケースでは特に便利
public class Enemy: MonoBehaviour
{
     [SerializeField] private EnemyRuntimeSetSO m_RuntimeSet;

     private void OnEnable()
     {
         m_RuntimeSet.Add(this);
     }

     private void OnDisable()
     {
         m_RuntimeSet.Remove(this);
     }
}
  • 制約として、実行時に ScriptableObject をインスペクターで確認すると Items リスト内の内容が表示されず、エラーが表示される
    • 実際にはリストは正常に機能している
    • 混乱を避けるなら [HideInInspector] をつけておく
    • また、カスタムエディタスクリプトとインスペクターを使用することで解決できる
      • 次のサンプルで紹介

パターンデモ

youtu.be

上記の動画にあるように

  • 「Spawn Block」ボタンでブロックの壁が生成され、ボールが衝突すると消える
  • Inspector に表示されている BlockRuntimeSet_Data アセットの Runtime Set Items に「Block0, Block」と生成・消滅するブロックが追跡されていることが分かる

GameObjectRuntimeSetSO

まずは、BlockRuntimeSet_Data アセットのベースになっている ScriptableObject である GameObjectRuntimeSetSO を見てみます。(一部省略しています)

[CreateAssetMenu(menuName = "GameSystems/GameObject Runtime Set", fileName = "GameObjectRuntimeSet")]
public class GameObjectRuntimeSetSO : DescriptionSO
{
    public System.Action ItemsChanged;

    [Header("Optional")]
    [Tooltip("Notify other objects this Runtime Set has changed")]
    [SerializeField, Optional] private VoidEventChannelSO m_RuntimeSetUpdated;

    private List<GameObject> m_Items = new List<GameObject>();
    public List<GameObject> Items {get => m_Items; set => m_Items = value;}

    public void Add(GameObject thingToAdd)
    {
        if (!m_Items.Contains(thingToAdd))
            m_Items.Add(thingToAdd);

        if (m_RuntimeSetUpdated != null)
            m_RuntimeSetUpdated.RaiseEvent();

        if (ItemsChanged != null)
            ItemsChanged();
    }

    public void Remove(GameObject thingToRemove)
    {
        if (m_Items.Contains(thingToRemove))
            m_Items.Remove(thingToRemove);

        if (m_RuntimeSetUpdated != null)
            m_RuntimeSetUpdated.RaiseEvent();

        if (ItemsChanged != null)
            ItemsChanged();
    }
}

まず、先程と同じように管理対象である List<GameObject> とそれを操作する Add/Remove メソッドがあるのが分かります。

それ以外に m_RuntimeSetUpdated.RaiseEvent()ItemsChanged()Add/Remove のそれぞれで呼び出されているのが分かります。

まず m_RuntimeSetUpdated なのですが、これは動画内に表示されていた以下の BlockRuntimeSet_DataRuntime Set Updated にセットされている BlockRuntimeSetUpdated_SO になります。

alt text

これは、動画内にあった「現在のブロックのカウント」を表示している部分で Listen されています。

続いて、ItemsChanged です。これはドキュメントにも記載がありますが、「Items リストに要素が追加または削除された際にエディタースクリプトに通知し、Items リストが変更された際にインスペクターを更新」という役割をもっています。

カスタムのエディタースクリプトScriptableObject でのイベントチャンネルを使えないため、普通に C# のイベントの仕組みを利用しているのでしょう。

GameObjectRuntimeSetSOEditor

カスタムのエディタースクリプトである GameObjectRuntimeSetSOEditor です。
必要な箇所だけに省略しています。

[CustomEditor(typeof(GameObjectRuntimeSetSO))]
public class GameObjectRuntimeSetSOEditor : Editor
{
    private GameObjectRuntimeSetSO m_RuntimeSet;

    private ListView m_ItemsListView;

    private Label m_ListLabel;

    private void OnEnable()
    {
        m_RuntimeSet = target as GameObjectRuntimeSetSO;

        m_RuntimeSet.ItemsChanged += OnItemsChanged;
    }

    private void OnDisable()
    {
        m_RuntimeSet.ItemsChanged -= OnItemsChanged;
    }

    // 省略箇所多数

    // Invokes every time the runtime set list changes
    private void OnItemsChanged()
    {
        // Format the label with an item counter
        if (m_ListLabel != null)
        {
            string message = "Runtime Set Items";
            message = (m_RuntimeSet == null) ? message + ":" : message + " (Count " + m_RuntimeSet.Items.Count + "):";
            m_ListLabel.text = message;
        }

        // Force refresh of the Inspector when the Items list changes
        if (m_ItemsListView != null)
            m_ItemsListView.Rebuild();
    }
}

OnEnable/OnDisable で先程の ItemsChangedOnItemsChanged メソッドを紐づけていることが分かります。

これによって、GameObjectRuntimeSetSO で管理しているアイテムの数の変更が通知され、Inspector の方も表示が更新されているという仕組みのようです。

カスタムのエディタースクリプトということで、難しそうに感じましたが、とても簡単に利用できることが分かります。

ランタイムセットを使用するメリット

  • GameObject やコンポーネントの集中管理リストが必要な際には欄ライムセットの利用を検討する
    • オブジェクトの検索 (Object.FindObjectOfTypeGameObject.FindWithTag) を利用するよりも低コスト
    • シングルトンよりも問題が発生しにくい
  • ScriptableObject であるため、シーン内の個別の GameObject から毒膣している
    • 監視対象ごとにラインタイムセットを作成しておくと便利

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「ランタイムセット」を確認しました。

eBooks を読んでいる時には微妙にわからない箇所もあったのですが、実例も交えての説明で理解が深まりました。

特に「リストで追跡・管理したいものに適している」というのは覚えておくべきだと思いました。

シングルトンとの違いについても、シングルトンであれば HogeSingleton.Instance のようにコード内に直接記述しなければいけない一方で、ScriptableObject の場合は、Inspector 経由の依存で済むため強い依存にはなりません。

いろいろと使い道のありそうなパターンだと思いました。

Unity ScriptableObject のデザインパターン「デリゲート」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「データコンテナ」を確認する - Self-Taught CODE Tokushima Tech Blog

前回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「データコンテナ」の内容を確認しました。全体として以下の 5 つがあり、今回は 2 つ目のデリゲートです。

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

デリゲートオブジェクト

Use ScriptableObjects as Delegate Objects | Unity

ストラテジーパターン

まず、デリゲートの使い方として「ストラテジーパターン (Strategy Pattern) が紹介されています。

alt text

ストラテジーパターンに関する詳細は省きますが、例として、EnemyUnit という MonoBehaviour で実装された敵ユニットがあった場合に、移動処理を直接記述するのではなく、ScriptableObject に移譲して、入れ替え可能にするケースが紹介されています。

当然ながらストラテジーパターンは ScriptableObject である必要はまったくなく、POCO でも実装は可能です。
しかし、デザイナーが入れ替えをしやすくするのに ScriptableObject で実装するのは都合が良さそうです。

プラガブル (Pluggable) な振る舞い

Context は StrategyBase という ScriptableObject に依存しています。
ScriptableObject は単体でもカスタムアセットを作ることで、同じ型のアセットを作ることができますが、Inspector からパラメータの値を変えることはできても、 振る舞い(つまり内部のメソッドの動作) までは変えられません

よって、先程の図のように StrategyBase という基底クラス ScriptableObject(これに動作は不要なので抽象クラス) に対して Strategy1, Strategy2 という子の ScriptableObject を作成しています。
ドキュメントにある通り、抽象基底クラスではなく Interface でも問題ありません。

プラガブル (Pluggable)」という用語は、このようなコードやアセット単位だけではなく、多くのソフトウェア開発の文脈で使われます。「入れ替え可能にしておく」ということ意識しておくと制作の助けになると思います。

使用例: AudioDelegate / パターンデモ

ここではパターンの方のデモの方を詳細に見ます。Ball がただ、壁やブロックに跳ね返って音が出るというだけのものです。

alt text alt text

ただ音が出るのではなく、ボールがいるエリアによって音が変わります。

壁やブロックには以下のように BallCollided_SO (ボールが衝突したことを通知) と SoundPlayed_SO (音を鳴らすように通知) という ScriptableObject のアセットが Broadcast 側 つまり「通知する側」としてつけられています。

このデモで重要なのは音を出すように通知する SoundPlayed_SO ですね。

そしてこれを Listen つまり「受信する側」なのは、DemoSounds というオブジェクトです。

Inspector Hierarchy
alt text alt text

DemoSounds は各エリアの音を保持する AudioSource**Pitch のオブジェクト群を子に持っています。
それらは AudioModifier を通して、SimpleAudioDelegateSO のアセットを保持しています。これが、それぞれの音源を出し分ける設定になっています。以下の 4 種類ですね。

alt text alt text alt text alt text
alt text alt text alt text alt text

SimpleAudioDelegateSO とその親の AudioDelegateSO は以下のような実装になっており、それぞれの設定値に応じて Play を実行することが分かります。

今回は、どれも AudioClip は一つですが、複数設定してランダムな音源を鳴らすこともできたようです。

public abstract class AudioDelegateSO : ScriptableObject
{
    public abstract void Play(AudioSource source);
}

[CreateAssetMenu(fileName ="SimpleAudioDelegate",menuName ="PaddleBall/Simple Audio Delegate")]
public class SimpleAudioDelegateSO : AudioDelegateSO
{
    [SerializeField] private AudioClip[] clips;
    [SerializeField] private RangedFloat volume;
    [SerializeField] private RangedFloat pitch;

    // If we have a valid clip, select a random clip, volume, and pitch. Then, play the sound.
    public override void Play(AudioSource source)
    {
        source.clip = clips[Random.Range(0, clips.Length)];
        source.volume = Random.Range(volume.minValue, volume.maxValue);
        source.pitch = Random.Range(pitch.minValue, pitch.maxValue);

        source.Play();
    }
}

そして、この Play を実行するのが AudioModifier です。

[RequireComponent(typeof(AudioSource))]
public class AudioModifier : MonoBehaviour
{
    [SerializeField] private AudioDelegateSO m_AudioDelegate;

    private AudioSource m_AudioSource;

    private void Awake()
    {
        m_AudioSource = GetComponent<AudioSource>();
    }

    public void Play()
    {
        m_AudioDelegate.Play(m_AudioSource);
    }
}

ここの構造を見て気付いたかもしれませんが、実は AudioModifier と同じオブジェクトについている AudioSource に設定されている以下ような Audio Resource (音源) は実際には利用されません。

SimpleAudioDelegateSO の方で、そこについている Clips オブジェクトの方に置き換えられるためです。

こちらの AudioSource は使われない こちらの Clips が使われる
alt text alt text

そして、最後に、DemoSounds に戻ってきます。
前述の通り、DemoSoundsSoundPlayed_SOListen しています。

つまり、音を鳴らす通知を受けるのは DemoSounds なのです。

alt text

上記のように DemoSoundsVector2EventChannelListner を経由して、SoundPlayed_SO から受け取った Vector2 の情報(つまり音を鳴らしたい場所) の情報を受取り、AudioSourceController.PlayClosestSource を呼び出しています。

AudioSourceController は、自分の子に AudioModifier が複数配置されていることを分かっているため、そこから受け取った Vector2 の座標情報に最も近い position を持つものを選び、その Play を実行しています。

これにより、Ball が衝突した場所に応じて、鳴る音が変化するという処理が実現されています。

イベントもボールは関係なく単に Vector2 を受け取り、それに対して音を鳴らすだけなので、ボールへの依存もありません。

ストラテジーパターンとして

この部分のストラテジーパターンは少し理解が難しく感じます。確かに AudioModifierSimpleAudioDelegateSO の関係は再生するクリップやそのオーディオ処理を入れ替えられる点でストラテジーパターンなのですが、実は AudioModifier 自体も SimpleAudioDelegateSO のアセットの数だけ作成されています。
つまり、切り替えに使われているのは実質 AudioModifier のオブジェクト群です。

例:目的システムにおけるストラテジーパターンの実装

続いて、Paddle Game の内容における「目的システム」の紹介です。以前の記事では「イベントチャンネル」の一部で私が紹介しましたが、ここでは「ストラテジーパターン」としての紹介になります。

まず、Paddle Game はどちらかのプレイヤーが「あるスコアに到達」すると勝敗が決まりゲームが終了します。この「ゲームの目的」を管理しているのが ObjectiveSOObjectiveManager です。

  • ObjectiveSO: 個別の目的を管理する
  • ObjectiveManager: 全ての目的を管理する

以下を見てもらうと分かる通り、ObjectvieManagerObjectives として ScoreObjectiveSO のアセットがリストとして紐づけられているのが分かり、「個別と全体」の関係が明らかです。
ただ、この Paddle Ball はスコアのみが勝利条件なので「目的は一つだけ」というサンプルです。

ObjectiveManager ScoreObjectiveSO
alt text alt text

そのままコードを見て確認します。

ObjectiveSO

ベースとなる ObjectiveSO 、つまり「個別の目的」は、シンプルに「完了イベントの通知 (CompleteObjective)」のみを担当しています。自身が管理する目的が完了したら通知ということですね。

public class ObjectiveSO : DescriptionSO
{
    [Space]
    [Tooltip("On-screen name")]
    [SerializeField] private string m_Title;

    [Header("Broadcast on Event Channels")]
    [Tooltip("Event sent that objective is complete")]
    [SerializeField] private VoidEventChannelSO m_ObjectiveCompleted;

    private bool m_IsCompleted;

    public bool IsCompleted => m_IsCompleted;
    public VoidEventChannelSO ObjectiveComplete => m_ObjectiveCompleted;

    protected virtual void CompleteObjective()
    {
        m_IsCompleted = true;
        m_ObjectiveCompleted.RaiseEvent();
    }

    public void ResetObjective()
    {
        m_IsCompleted = false;
    }
}

ScoreObjectiveSO

ObjectiveSO を継承した ScoreObjectiveSO はこの Paddle Game のスコア用に拡張されています。

ScoreListEventChannelSO のイベント通知を受け取って「スコアが目標に到達したか」「していれば完了通知」を行います。 この Paddle Game は二人プレイなので、「二人どちらかのスコアが目標に到達したか」ですが、ScoreListEventChannelSOList<PlayerScore> を渡してくるので、おそらくプレイヤーが増えても対応できるようになっています。

[CreateAssetMenu(menuName = "Objectives/Score Objective", fileName = "ScoreObjective")]
public class ScoreObjectiveSO : ObjectiveSO
{
    [Header("Score Goal")]
    [Tooltip("Score to win")]
    [SerializeField] private int m_TargetScore = 1;

    [Header("Broadcast on Event Channels")]
    [Tooltip("Notify listeners that a Player has reached winning score")]
    [SerializeField] private PlayerScoreEventChannelSO m_TargetScoreReached;

    [Header("Listen to Event Channels")]
    [Tooltip("Signal when ScoreManager updates")]
    [SerializeField] private ScoreListEventChannelSO m_ScoreManagerUpdated;

    public int TargetScore => m_TargetScore;

    private void OnEnable()
    {
        m_ScoreManagerUpdated.OnEventRaised += UpdateScoreManager;
    }

    private void OnDisable()
    {
        m_ScoreManagerUpdated.OnEventRaised -= UpdateScoreManager;
    }

    private void UpdateScoreManager(List<PlayerScore> playerScores)
    { 
        if (HasReachedTargetScore(playerScores))
            CompleteObjective();
    }

    private bool HasReachedTargetScore(List<PlayerScore> playerScores)
    { 
        foreach (PlayerScore playerScore in playerScores)
        {
            if (playerScore.score.Value >= m_TargetScore)
            {
                m_TargetScoreReached.RaiseEvent(playerScore);
                return true;
            }
        }
        return false;
    }
}

ObjectiveManager

ObjectiveManager は上述の通り、複数の ObjectiveSO を管理しています。

m_ObjectiveCompleted (これは ScoreObjectiveSO が Broadcast しています) を受取り、管理する全ての ObjectiveSO が完了しているかを確認します。

完了していれば m_AllObjectivesCompleted で「全ての目標の完了を通知」します。(ちなみに、このイベントはゲーム自体を管理する GameManager が受信しています)

public class ObjectiveManager : MonoBehaviour
{

    [Tooltip("List of Objectives needed for win condition.")]
    [SerializeField] private List<ObjectiveSO> m_Objectives = new List<ObjectiveSO>();

    [Header("Broadcast on Event Channels")]
    [Tooltip("Signal that all objectives are complete.")]
    [SerializeField] private VoidEventChannelSO m_AllObjectivesCompleted;

    [Header("Listen to Event Channels")]
    [Tooltip("Gameplay has begun.")]
    [SerializeField] private VoidEventChannelSO m_GameStarted;
    [Tooltip("Signal to update every time a single objective completes.")]
    [SerializeField] private VoidEventChannelSO m_ObjectiveCompleted;

    private void OnEnable()
    {
        m_GameStarted.OnEventRaised += OnGameStarted;
        m_ObjectiveCompleted.OnEventRaised += OnCompleteObjective;
    }

    // Unsubscribes to prevent errors
    private void OnDisable()
    {
        m_GameStarted.OnEventRaised -= OnGameStarted;
        m_ObjectiveCompleted.OnEventRaised -= OnCompleteObjective;
    }

    // Returns true if all objectives are complete
    public bool IsObjectiveListComplete()
    {
        foreach (ObjectiveSO objective in m_Objectives)
        {
            if (!objective.IsCompleted)
            {
                return false;
            }
        }
        return true;
    }

    private void OnGameStarted()
    {
        foreach (ObjectiveSO objective in m_Objectives)
        {
            objective.ResetObjective();
        }
    }

    private void OnCompleteObjective()
    {
        if (IsObjectiveListComplete())
        {
            if (m_AllObjectivesCompleted != null)
                m_AllObjectivesCompleted.RaiseEvent();
        }
    }
}

ストラテジーパターンとして

Audio の例では AudioDelegateSOSimpleAudioDelegateSO の関係がありましたが、こちらはそれが ObjectvieSOScoreObjectiveSO になっています。

こちらの例は、他に TimeIsUpObjectiveSO (時間切れ) など、他のゲームの完了条件も作れそうです。

いずれにしろ、利用するクラスである AudioModifierObjectiveManager が「何が設定されているかを意識せずに使える」「それを交換でき、それで振る舞いが変わる」という点で、どちらもストラテジーパターンとして利用できそうです。

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「デリゲート」を確認しました。

ほとんどストラテジーパターンの説明でしたが、単に ScriptableObject の「カスタムアセットが入れ替え可能」というだけではなく、「振る舞いを変えた ScriptableObject の入れ替え」という手法の紹介という点でも面白かったです。

Unity ScriptableObject のデザインパターン「データコンテナ」を確認する

alt text

前回: 「ScriptableObject を使って Unity でモジュラーゲーム アーキテクチャを作成する」を読んだ - Self-Taught CODE Tokushima Tech Blog

前回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトを eBooks の内容に照らし合わせて確認しました。

ただ、Paddle Ball プロジェクトにはゲーム以外にもデザインパターンを説明したコンテンツも用意されており、前回触れられなかった eBooks で紹介されていたパターンをそこで拾っていこうと思います。

alt text

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

の紹介がありそうです。

サンプル用のドキュメント

https://github.com/UnityTechnologies/PaddleGameSO

Paddle Ball プロジェクトは Asset Store から入手しましたが、GitHub リポジトリも用意されています。

そこには、それぞれのサンプル用のドキュメントへのリンクも貼られているので、今回はそちらを参照しつつ eBooks の内容も振り返りながら見ていきたいと思います。

データコンテナ

Separate Game Data and Logic with ScriptableObjects | Unity

ゲームプレイデータと動作ロジックの分離 (またはシンプルに"データとロジックの分離")」が重要であり、ScriptableObject は特に 静的データ の保存に適しています。

関心事の分離 (Separation of Concern / SoC) の重要性はゲーム以外でもソフトウェア設計でよく出てきます。

ワークフロー

基本的には以下のようになります。

  1. ScriptableObject の定義
    • 2 のために CreateAssetMenuAttribute 属性を付与する
  2. 定義からアセットの作成
  3. アセットに値を設定
    • Inspector からデータを入力できる
  4. アセットを変数やフィールドから参照
    • 3 で入力・変更されたデータはプロジェクト全体に反映される

特に重要なのは「プロジェクト全体で参照可能」という点です。

もし、ScriptableObjectCreateInstance メソッドでインスタンス化してもプロジェクト全体で使えるデータコンテナとしては利用できません。

ScriptableObject と MonoBehaviour の比較

これは eBooks の方でも十分に言及されていましたが、「Transform などの余計なオーバーヘッドが無く軽量」というのが、より データの保存先として優れている という点です。

デモの説明

youtu.be

上記のデモの動きを見てもらって分かる通り、CreditsSO という ScriptableObject から作られたアセットである Credits_Data の内容を変更し Update をクリック することで画面の値も変更されています。

これは、データ自体は即時に変更されていますが、UI Toolkit ベースの画面は変更通知をするまでリフレッシュされないため、Update ボタンでの通知が必要になっています。

また、上記のデモ動画には入っていませんが、テストプレイ終了後にも変更した Credits_Data のデータは元に戻らずそのままになっています。

値の更新をイベントに紐づけるサンプル実装もあります。

[CreateAssetMenu(menuName = "ExampleSO")]
public class ExampleSO : ScriptableObject
{
    public Action OnValueChanged;
    
    [SerializeField] private int m_ExampleValue;
    public int ExampleValue
    {
        get => m_ExampleValue;
        set
        {
            m_ExampleValue = value;
            OnValueChanged?.Invoke();
        }
    }
}

MVVM の INotifyPropertyChanged を思い出しますが、この辺は簡略化してくれるライブラリがあるかもしれませんね。

flyweight パターンを使ったゲームデータ格納方法

ここでは、Paddle Game サンプルの方で、複数の Paddle オブジェクトの「速度・質量・物理の反発係数」などの共通設定を GameDataSO で設定していました。

Paddle 自体も Prefab 化されているので、そこまで設定の共通化の恩恵があるようにも見えませんが、まず「個別のデータのメモリ容量の切り離し」つまり flyweightパターン の適用による軽量化のメリットに加えて、「データとロジックの分離」という観点からは Paddle そのものも Prefab のパラメータを変更するよりも良さそうです。

Paddle Ball のゲームデータ

Default (CLASSIC) Hockey Foosball
alt text alt text alt text

前回の記事でも見ましたが、GameDataSO は上記の 3 つのアセットが作成されており、GameManager というゲーム管理のオブジェクトが利用しています。

これらはデータコンテナとして機能しており、テストプレイをしながら自由に変更できます。

加えて、先程 Paddle もこのデータを利用しているとありましたが、共有データとしてどのオブジェクトからもアクセス可能 になっています。

⚠️ プロジェクト全体の共有データということもあり、実行時には変更されない 静的データ として扱うという前提が大事になりますね。

デュアルシリアライゼーションの例

この LevelLayoutSO の例は前回の記事でも触れましたので詳細は割愛します。

ここでのポイントとしては、以下のように同じ Hockey 用のレベル設定が ScriptableObject のカスタムアセットとして存在する場合と、JSON として存在する場合、JSON から呼び出す際は、CreateInstance メソッドで LevelLayoutSOインスタンスを作成し、そこに JSON のデータをバインドするという方式になる点です。

alt text

{
    "m_Description": "Level layout for \"hockey\" mode.",
    "m_BallStartPosition": {
        "x": 0.0, "y": 0.0, "z": 0.0
    },
    "m_Paddle1StartPosition": {
        "x": -4.5, "y": 0.0, "z": 0.0
    },
    "m_Paddle2StartPosition": {
        // 略
    },
    "m_Goal1": {
        "position": {
            "x": -6.699999809265137, "y": 0.0, "z": 0.0
        },
        "rotation": {
            "x": 0.0, "y": 0.0, "z": 0.0
        },
        "localScale": {
            "x": 0.30000001192092898, "y": 3.5, "z": 1.0
        }
    },
    "m_Goal2": {
        // 略
    },
    "m_LevelWalls": [
        {
            "position": {
                "x": 0.0, "y": -5.199999809265137, "z": 0.0
            },
            "rotation": {
                "x": 0.0, "y": 0.0, "z": 0.0
            },
            "localScale": {
                "x": 18.0, "y": 1.100000023841858, "z": 1.0
            }
        },
        {
            // 略
        },
        // 以下略
    ],
    "m_LevelPrefab": {
        "instanceID": 0
    },
    "m_JsonFilename": "LevelLayout.json"
}

データコンテナの活用方法

以下のような活用例が紹介されています。

  • ゲーム設定
  • キャラクター/敵キャラクターの属性設定
  • インベントリおよびアイテムシステム
  • 会話システムとストーリー管理
  • レベルデータと進行状況管理
  • 音声クリップ
  • アニメーションクリップ

どれも「データとロジックの分離」と「データの共有での軽量化」の例になっているように見えます。

Unreal Engine では

Unreal Engine では Struct と DataTable がこの ScriptableObject のデータコンテナ用途に最も近いと思います。

DataTable は CSVJSON との Import/Export 機能も標準でついているため、よりデータコンテナとして意識されていますね。

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「データコンテナ」を確認しました。

データコンテナはデータとロジックの分離という点でも、取り入れやすいパターンになりそうです。

次回は「デリゲート」を確認します。