5. コアコンセプト

すべての CGS サービスの背後にある 5 つの考え方 — ブートストラップ、サービスロケーター、クロック、イベント、Null 実装 — と用語集。

フレームワーク全体は 5 つの考え方で説明できます。これらを知れば、23 のサービスすべてが同じように動くことが分かります。この章では各アイデアを平易な言葉で、プロジェクトに貼り付けられる短いコードとともに扱います。章末の用語集で、このマニュアルで使うすべての技術用語を定義します。

5.1 ブートストラップ: すべては自動で始まる

Common Game System には、シーンにドラッグするプレハブも、マネージャーオブジェクトも、初期化呼び出しもありません。Play を押すと、最初のシーンがロードされる前に、Unity がフレームワークの起動コードを自動で実行します。この起動ステップをブートストラップと呼びます。ブートストラップは 23 のサービスすべてを正しい順序で生成し、あなたのコードが見つけられるように 1 つずつ登録します。

正しく動作したことは、Play 後に Console を見れば確認できます。起動の最後の行にはこう書かれています:

bootstrap complete (v2.1.0, 23 services)

ブートストラップをあなたが呼ぶことは決してありません。Play セッションごとに正確に 1 回実行され、2 回目の呼び出しは意図的にエラーを投げます。あなたのシーンのどの Awake() が走る頃には、すべてのサービスがすでに生きて待機しています。完全なリファレンス: Bootstrap

5.2 サービスロケーター: 一度だけ尋ねて、答えを持ち続ける

サービスとは、1 つの自己完結した機能 — セーブ、オーディオ、タイマー、入力 — で、単一の C# インターフェイスを通じてアクセスできるものです。サービスロケーターは、それらすべてを収めた電話帳です。インターフェイスを指定してサービスを尋ねると、実行中のインスタンスが返ってきます:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // run after the framework has started
public class GameSetup : MonoBehaviour
{
    private ITimeService _time;
    private ISaveService _save;

    private void Awake()
    {
        // Ask once, keep the answer.
        _time = ServiceLocator.Resolve<ITimeService>();
        _save = ServiceLocator.Resolve<ISaveService>();
    }
}

これを高速かつ安全に保つ習慣は 2 つです:

  • Awake()Start() で一度だけ解決し、結果をフィールドに保存する。 各ルックアップは辞書検索です。毎フレーム呼ぶのは、何の得もなく時間を浪費するだけです。
  • Awake() でサービスを解決するクラスには [DefaultExecutionOrder(100)] をつける。 これにより Unity はあなたのスクリプトをフレームワーク自身のオブジェクトの後に実行するので、すべてのサービスが準備済みになります。

サービスが存在しない可能性がある場合 — 例えば任意の Unity パッケージを削除した場合 — は、代わりに ServiceLocator.TryResolve<T>(out var service) を使ってください。例外を投げる代わりに false を返します。完全なリファレンス: Service Locator

登録済みのすべてのサービスを一覧する Service Debugger ウィンドウ

Service Debugger ウィンドウ(Tools > Common Game System > Service Debugger)は、プレイ中に登録済みのすべてのサービスを一覧します。期待するサービスがここにない場合、その任意 Unity パッケージが未インストールの可能性が高いです — 第 7.2 章を参照してください。表示される数は 23 より多いことがあります — フレームワークは 23 の公開サービスに加えて、いくつかの内部ヘルパーを登録するためです。

5.3 クロック: 3 つの速度で流れる時間

クロックとは、独立した時間の流れです。フレームワークは 3 つを動かしています: Clock.GameplayClock.UIClock.Background です。それぞれは、他の 2 つに触れることなくポーズやスローダウンができます。これは古典的なバグを解決します: ゲームをポーズしたら、メニューのアニメーションまで凍りつき、音楽も止まってしまう、というあれです。

  • Gameplay — あなたのワールド: キャラクター、物理反応、クールダウン。ポーズメニューはこのクロックを止めます。
  • UI — メニューとオーバーレイ。ゲームプレイのポーズ中も動き続けるので、ボタンはアニメーションし続けます。
  • Background — 音楽など、決して止まってはいけないもの。

ポーズメニューは 2 行になります:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class PauseMenu : MonoBehaviour
{
    private ITimeService _time;

    private void Awake() => _time = ServiceLocator.Resolve<ITimeService>();

    public void Open()  => _time.Pause(Clock.Gameplay);   // world freezes
    public void Close() => _time.Resume(Clock.Gameplay);  // world resumes
}

時間は Unity の Time.deltaTime の代わりに、クロック単位で _time.DeltaTime(Clock.Gameplay) で読み取ります。スローモーションは 1 回の呼び出しです: _time.SetTimeScale(Clock.Gameplay, 0.3f) — UI と音楽は通常速度のままです。スケジューラーのタイマーとトゥイーンサービスのアニメーションはそれぞれクロックにバインドされるので、追加コードなしで正しくポーズ・スローダウンします。ルールは 1 つ: Unity のグローバルな Time.timeScale を自分で設定しないこと。クロック単位のシステムと衝突してしまいます。完全なリファレンス: Time

5.4 イベント: 購読し、ハンドルを持ち続け、破棄する

イベントバスを使うと、システム同士が互いを知らずに対話できます。あるスクリプトがイベントオブジェクトをパブリッシュすると、そのイベント型のすべてのサブスクライバーが、購読した順に即座に呼び出されます。購読すると小さなハンドルが返ります。そのハンドルを破棄すると購読解除になります。破棄を忘れると、ハンドラーは永久に登録されたままです — それはメモリリークです。

using System;
using CommonGameSystem.Core;
using UnityEngine;

// Events are plain classes you define yourself.
public class ScoreChanged
{
    public int NewScore;
}

[DefaultExecutionOrder(100)]
public class ScoreLabel : MonoBehaviour
{
    private IEventBus _events;
    private IDisposable _subscription;

    private void Awake()   => _events = ServiceLocator.Resolve<IEventBus>();
    private void OnEnable() => _subscription = _events.Subscribe<ScoreChanged>(OnScore);
    private void OnDisable() => _subscription?.Dispose(); // always unsubscribe

    private void OnScore(ScoreChanged e) => Debug.Log($"Score: {e.NewScore}");
}

パブリッシュはどこからでも 1 行です: _events.Publish(new ScoreChanged { NewScore = 100 });。知っておくべき詳細が 2 つあります: イベント型はクラスでなければなりません(構造体は不可)。そして、例外を投げたサブスクライバーが他のサブスクライバーをブロックすることはありません — エラーはログに記録され、配信は続行されます。完全なリファレンス: Event Bus

5.5 Null 実装: すべてのサービスにオフスイッチがある

すべてのサービスには、対応する Null 実装が同梱されています — あらゆる呼び出しを受け入れて何もしないバージョンです。これを登録すると、呼び出し側を 1 つも変更することなく、そのサブシステムをオフにできます。全体のオーディオをどこでもミュートするのは 1 行です:

ServiceLocator.Replace<IAudioService>(new NullAudio());

プロジェクト内のすべての PlayMusicPlaySfx 呼び出しは、変わらずコンパイルされ、変わらず実行されます — ただ何もしないだけです。テスト、ヘッドレスサーバー、ボイス収録セッション、あるいはサブシステムを独自ソリューションに置き換えるときに便利です。第 7.4 章に 23 サービスすべてのオフスイッチの一覧と、セッション中に特定のサービスを差し替える際に追加すべきクリーンアップの 1 行が載っています。

5.6 用語集

用語意味
サービス自己完結したフレームワーク機能(セーブ、オーディオ、タイマー)の 1 つ。単一の C# インターフェイスを通じて使用します。
Resolve(解決)サービスロケーターに、インターフェイスを指定してサービスを尋ねること。一度だけ行い、参照をフィールドに保持します。
ブートストラップ最初のシーンがロードされる前に、23 のサービスすべてを生成・登録する自動起動ステップ。あなたが呼ぶことはありません。
クロック3 つの独立した時間の流れ(Gameplay、UI、Background)の 1 つ。それぞれ個別にポーズやスローダウンができます。
トゥイーン値を開始点から終了点まで、指定した時間をかけて滑らかに動かす短いアニメーション。形はイージングカーブで決まります。
アクションマップUnity Input System アセット内の、名前つき入力アクションのグループ("Gameplay" や "Menu" など)。有効化されたマップだけがプレイヤーに反応します。
Addressables直接参照の代わりにテキストのアドレスで、アセットをオンデマンドにロードするための Unity の任意パッケージ。
IL2CPPC# をネイティブコードに変換する Unity のビルドモード。未使用に見えるクラスを除去するため、セーブ用クラスには link.xml のエントリが必要です(第 6.3 章)。

次へ: 6. セットアップ手順(図解つき)