🌠

機運到来!Photon Quantum

2023/12/23に公開

これは「Unity Advent Calendar 2023」の23日目の記事になります。

はじめに

Photon Quantum」は「Photon Fusion」と並びPhotonシリーズの主力製品となっているリアルタイムオンラインマルチプレイゲーム開発エンジンです。以前までは別途契約が必要だったことなどもあって、SDKを触ることすら難しい所があったのですが、2023年6月に開発用の無料プランが利用可能になった(その後さらに100CCUプランなども追加された)ため、誰でも手軽にQuantumを始められる環境が整ってきました。

https://doc.photonengine.com/ja-jp/quantum/v2/quantum-intro

とはいえ、記事執筆時点(2023年12月)で、まだネット上には公式以外の情報がほぼ無いため、この記事では、まずそもそもQuantumとは一体何なのか?という話から、基本的な実装を例にした開発上のヒントの話などを紹介していきたいと思います。

Photon Quantumとは?

決定論的ロールバック

Quantum最大の特徴は、完全に決定論的(Deterministic)であることです。「決定論的である」とは、「同じ入力を与えると、必ず同じ出力が得られる(プレイヤーの入力さえわかれば、完全に正確なゲームの状態を予測することができる)」ようになっているということです。

入力の例 状態の例
コントローラーの方向キーの入力
コントローラーのボタンの入力
アバターの位置
アバターの向き
アバターのアニメーションのパラメーター

オンラインゲームにおいて「入力がわかれば正確な状態もわかる」ようになっていると、状態の同期(ネットワーク上で状態を送受信する処理)が一切不要になるので、データ通信量が劇的に削減されるほか、(従来のネットコードでは実現困難な)低遅延で非常に複雑なオブジェクトの状態の同期が必要なゲームなどが容易に実装できるようになります。

「入力がわかれば正確な状態もわかる」とは「入力がわかるまでは正確な状態を確定できない」ということでもありますが、Quantumはデフォルトで、予測(Prediction)と、ロールバック(Rollback)機能を備えています。他プレイヤーの入力の受信を待つことなく、ローカルでゲームの状態の更新を進められるので、遅延(ラグ)は大きく軽減されるでしょう。

https://kakuge-checker.com/topic/view/07248/

補足:Unityは決定論的でないのか?

結論から言えば、決定論的ではないです。

  • フレームレート(Time.deltaTime)は可変する
  • 浮動小数点数(floatdouble)の誤差が発生する
  • 物理エンジン(PhysX)が決定論的ではない(DOTSのUnity Physicsも上記の浮動小数点数の誤差問題によって、異なるプラットフォーム間で決定論的ではない)

などの影響によって、同じ入力を与えても異なるゲームの状態になる可能性があります。そのため、Quantum以外の決定論的ではないネットコードを使用して、入力のみを通信する(状態を一切通信しない)実装を行っても、状態が正確に同期される保証はありません

https://soysoftware.sakura.ne.jp/archives/2277
https://gafferongames.com/post/floating_point_determinism/

補足:バタフライエフェクト

Quantumでは、ゲームロジックを完全に決定論的に実装する必要があります。「ほんのわずかズレる程度なら問題にならないのでは?」と思うかもしれませんが大問題になります。

これを説明する有名な言葉が「バタフライエフェクト」です。Quantumは状態を一切同期せずにシミュレーションを進めるため、このバタフライエフェクトが発生する可能性があります。

「蝶1匹の羽ばたきによって生まれた小さな空気の乱れが、めぐりめぐって地球の反対側で大嵐を引き起こす」ように、「ほんのわずかでもゲームの状態がズレたまま何百何千フレームに渡ってシミュレーションを進めていくと、ゲームの状態や結果には非常に大きな違いが生まれる」ようになってしまうことに留意してください。

Quantum ECS

Quantumは、独自のECS(Entity Component System)で設計されていて、非常に高速で動作し、実行時にGCも発生しません。ただし、そのパフォーマンスを最大限に発揮するには、ECSアーキテクチャの基本的な原理を押さえた上で、QuantumのDSL(ドメイン固有言語)や、C#のポインタを駆使したunsafeなコードを記述する必要があるので、開発者にはある程度のスキルが求められるでしょう。

また、Quantumのシミュレーションは、Unityに依存せずに完全に分離されています。開発者は、ゲーム中の全てのデータとロジックをQuantumで実装し、Unityでは描画のみを行います。

実装のレイヤーを明確に分けることを強制されるため、ある意味クリーンなコードの設計には役立つと言えますが、(決定論的ではなくなってしまうので)Unity側でゲームロジックを実装することは一切できないので注意が必要です。

Photon Fusionとの比較

Quantumは様々なジャンルのオンラインマルチプレイゲーム開発に適しており、Fusionよりも安定してパフォーマンスが出るケースが多いものの、Fusionの上位互換ではないことに注意です。

決定論的シミュレーションには、大きな利点が多いですが、以下のような欠点も挙げられます。

  • 各プレイヤーは、入力から「ゲーム中で同期している全ての要素の状態」を求める計算が必要になる:視錐台カリングなどによる不要な処理の省略ができない(省略してしまうと決定論的ではなくなってしまう)ので、ゲームの規模(要素の多さ)がCPU負荷に大きく影響を与える。ハードウェア性能が低いプラットフォームで、大人数バトルロイヤルやメタバースをQuantumで動作させるのは困難かもしれない。
  • 各プレイヤーは、「ゲーム中で同期している全ての要素の状態」を知っている必要がある不完全情報ゲームでも、全てのプレイヤーは完全な情報を持たなくてはならない(完全な情報が与えられないと決定論的なシミュレーションが進められない)ので、例えばカードゲームで、プレイヤーが本来知りえない相手の手札を見るチートなどに弱くなるリスクがある。

https://doc.photonengine.com/ja-jp/quantum/v2/manual/cheat-protection

上記のような点が問題になるケースでは、一般的なクライアント/サーバー型(サーバーにゲームの状態を更新する権限を持たせて、必要な情報のみをサーバーからクライアントに送信できる)を採用できるFusionを選択する方が良いでしょう。

また、Quantumは非常に優れたフレームワークですが、それ故に覚えることは多く、学習コストはFusion以上に高めな印象です。もし一度触ってみて難しく感じるようであれば、Fusion(共有モード)から学び始めるのも良いかもしれません。

以下の図は、ざっくりとしたPhoton製品選択フローチャートです。もちろんアプリケーションの要件によって適切な製品は変わる可能性があるので、一つの目安として参考にしてください。

Photon Quantumをどう学ぶか?

まず最初にQuantumを始めるなら、公式ドキュメントにあるチュートリアル「Quantum 100」を一通り進めてみましょう。

https://doc.photonengine.com/ja-jp/quantum/v2/quantum-100/overview

その後は、公式ドキュメントの「Game Samples」「Technical Samples」からサンプルプロジェクトをダウンロードして、Quantumを使用しているゲームがどのような構成で実装されているのか?を実際のコードを読んで理解していくのが良いでしょう。

コードを読んでいてわからない点や気になった点は、公式ドキュメントの「Manual」から該当する機能を調べて読んでいくことで理解が早まると思います。

個人的にオススメのサンプルプロジェクトは「Blueless」です。

https://doc.photonengine.com/ja-jp/quantum/v2/game-samples/blueless

Photon Quantumの実装ヒント

ここからは、フリーゲーム投稿サイトのunityroomに公開しているシンプルなバトルロイヤルゲームの実装を例にして、Quantumを使用したオンラインマルチプレイゲーム開発に役立つヒントをいくつか紹介したいと思います。

https://unityroom.com/games/skate20

開発環境

  • Unity 2022.3.14f1
  • Quantum SDK 2.1.7 Stable Build 1164
  • JetBrains Rider 2023.2.3

Quantumセッションの開始

ネットワークに接続してQuantumのセッションを開始するまでには、大きく3つのステップを踏む必要があります。

  1. Photon Realtime APIで、マスターサーバーに接続してマッチメイキングを行う
  2. Photon Realtime APIで、Quantumプラグインを有効にしたルームへ参加する
  3. Photon Quantum APIで、(任意のタイミングで)Quantumセッションを開始する

12については、PUNを使ったことがある人であれば馴染みのある流れかと思います。ただし、PUNのように接続周りのAPIのラッパーは用意されていないため、Photon Realtime API(LoadBalancingClientクラス)を直接利用してルームへの参加までを行うことになります。

詳しくは、Quantum SDKのデモシーン(MenuGame)の実装コードが参考になるでしょう。

https://doc.photonengine.com/ja-jp/quantum/v2/manual/game-session/starting-session


ゲームのタイトル画面

以下のコードは、作成したゲームの接続周りの実装の抜粋です。Quantum SDKのデモシーンの実装から必要最小限のものを抜き出して、一つのクラスにまとめたものになっています。

quantum_unity - GameLauncher.cs
using System.Collections.Generic;
using Photon.Deterministic;
using Photon.Realtime;
using Quantum;
using UnityEngine;

// QuantumCallbacksを継承して、Quantumセッション関連のコールバックを受け取る
// IConnectionCallbacksを実装して、Realtimeの接続関連のコールバックを受け取る
// IMatchmakingCallbacksを実装して、Realtimeのマッチメイキング関連のコールバックを受け取る
public class GameLauncher : QuantumCallbacks, IConnectionCallbacks, IMatchmakingCallbacks
{
    [SerializeField] private RuntimeConfig runtimeConfig; // セッションの設定
    [SerializeField] private RuntimePlayer runtimePlayer; // プレイヤーの設定

    private LoadBalancingClient client;

    private void Update() {
        client?.Service();
    }

    // タイトル画面の「参加する」ボタン押下時の処理
    private void OnClickPlayButton() {
        var appSettings = PhotonServerSettings.CloneAppSettings(PhotonServerSettings.Instance.AppSettings);
        appSettings.AppVersion = Application.version;

        client = new LoadBalancingClient();
        client.LocalPlayer.NickName = GameManager.SaveData.PlayerName;
        client.AddCallbackTarget(this);
        client.ConnectUsingSettings(appSettings);
    }

    // マスターサーバーへの接続が成功した時のコールバック
    void IConnectionCallbacks.OnConnectedToMaster() {
        var enterRoomParams = new EnterRoomParams();
        enterRoomParams.RoomOptions = new RoomOptions();
        enterRoomParams.RoomOptions.MaxPlayers = 20;
        // Quantumプラグインを有効にする
        enterRoomParams.RoomOptions.Plugins = new[] { "QuantumPlugin" };
        // ルームへ参加する(参加できるルームが存在しなければルームを作成して参加する)
        client.OpJoinRandomOrCreateRoom(new OpJoinRandomRoomParams(), enterRoomParams);
    }

    // ゲームサーバーへの接続が成功した時のコールバック
    void IMatchmakingCallbacks.OnJoinedRoom() {
        // Quantumセッションを開始する
        QuantumRunner.StartGame(client.LocalPlayer.UserId, new QuantumRunner.StartParameters {
            RuntimeConfig = runtimeConfig,
            DeterministicConfig = DeterministicSessionConfigAsset.Instance.Config,
            GameMode = DeterministicGameMode.Multiplayer,
            PlayerCount = client.CurrentRoom.MaxPlayers,
            LocalPlayerCount = 1,
            NetworkClient = client
        });
    }

    // Quantumセッションが開始された時のコールバック
    public override void OnGameStart(QuantumGame game) {
        // RuntimePlayer(プレイヤーの設定)をアップロードする
        runtimePlayer.PlayerName = client.LocalPlayer.NickName;
        game.SendPlayerData(runtimePlayer);
    }

    void IConnectionCallbacks.OnConnected() {}
    void IConnectionCallbacks.OnDisconnected(DisconnectCause cause) => client.RemoveCallbackTarget(this);
    void IConnectionCallbacks.OnRegionListReceived(RegionHandler regionHandler) {}
    void IConnectionCallbacks.OnCustomAuthenticationResponse(Dictionary<string, object> data) {}
    void IConnectionCallbacks.OnCustomAuthenticationFailed(string debugMessage) {}
    void IMatchmakingCallbacks.OnFriendListUpdate(List<FriendInfo> friendList) {}
    void IMatchmakingCallbacks.OnCreatedRoom() {}
    void IMatchmakingCallbacks.OnCreateRoomFailed(short returnCode, string message) => client.Disconnect();
    void IMatchmakingCallbacks.OnJoinRoomFailed(short returnCode, string message) => client.Disconnect();
    void IMatchmakingCallbacks.OnJoinRandomFailed(short returnCode, string message) => client.Disconnect();
    void IMatchmakingCallbacks.OnLeftRoom() => client.Disconnect();
}

セッション設定とプレイヤー設定

実行時にUnityからQuantumへ任意のカスタムデータを渡す仕組みとして、QuantumにはRuntimeConfigRuntimePlayerが用意されています。

  • RuntimeConfig:セッション開始時のQuantumRunner.StartGame()の引数に渡すことで、セッション固有の設定を同期することができる
  • RuntimePlayer:セッションが開始された後にQuantumGame.SendPlayerData()を呼び出すことで、各プレイヤー固有の設定を同期することができる

作成したゲームでは、RuntimeConfigにはGameSpecというデータアセット(後述)の参照を追加し、RuntimePlayerにはstring型の「プレイヤー名」を追加しています。

quantum_code - RuntimeConfig.User.cs
  using Photon.Deterministic;

  namespace Quantum;

  partial class RuntimeConfig
  {
+     public AssetRefGameSpec GameSpec; // ゲーム設定のデータアセットの参照

+     partial void SerializeUserData(BitStream stream) {
+         stream.Serialize(ref GameSpec);
+     }
  }
quantum_code - RuntimePlayer.User.cs
  using Photon.Deterministic;

  namespace Quantum;

  partial class RuntimePlayer
  {
+     public string PlayerName; // プレイヤー名
      public AssetRefEntityPrototype AvatarPrototype; // アバターのプレハブ(EntityPrototype)の参照

      partial void SerializeUserData(BitStream stream) {
+         stream.Serialize(ref PlayerName);
          stream.Serialize(ref AvatarPrototype);
      }
  }

https://doc.photonengine.com/ja-jp/quantum/v2/manual/config-files

データアセット

Quantumのデータアセットは、不変(Immutable)なデータを保持する通常のC#クラスです。作成したゲームでは、RuntimeConfigに追加したデータアセットGameSpecで、ゲームの各設定値を定義しています。

quantum_code - Game.qtn
asset GameSpec;
quantum_code - GameSpec.cs
using Photon.Deterministic;

namespace Quantum;

public partial class GameSpec
{
    public FP ReadyDuration; // 準備フェーズの時間(秒)
    public FP ActionDuration; // 対戦フェーズの時間(秒)
    public FP ResultDuration; // 結果フェーズの時間(秒)
    public int PlayersToBegin; // 参加プレイヤー数がこの値以上になると対戦開始
    public int PlayersToEnd; // 生存プレイヤー数がこの値以下になると対戦終了
    public FP SafeZoneRadius; // 対戦開始時のセーフゾーンの半径
}

Quantumのデータアセットは、UnityからScriptableObjectベースのアセットを生成できます。作成したゲームでは、「デバッグ用の設定」と「リリース用の設定」の二つのアセットを準備しておくことで、quantum_codeプロジェクトをビルドし直すことなく、ゲームの動作を簡単に変更できるようにしていました。


インスペクター上から、データアセットをアタッチする


デバッグ用の設定アセット


リリース用の設定アセット

https://doc.photonengine.com/ja-jp/quantum/v2/manual/assets/assets-simulation
https://doc.photonengine.com/ja-jp/quantum/v2/manual/assets/assets-unity

アバターの生成と破棄

プレイヤーがQuantumセッションを開始した後にアバターを生成する処理は「Quantum 100」と同じです。作成したゲームでは、ここにISignalOnPlayerDisconnectedを実装して、プレイヤーがQuantumセッションを切断した時にアバターを破棄する処理を追加しています。

quantum_code - AvatarSpawnSystem.cs
  using Photon.Deterministic;
  
  namespace Quantum.Skete;
  
  public unsafe class AvatarSpawnSystem : SystemSignalsOnly, ISignalOnPlayerDataSet, ISignalOnPlayerDisconnected
  {
      void ISignalOnPlayerDataSet.OnPlayerDataSet(Frame f, PlayerRef player) {
          var data = f.GetPlayerData(player);
          var entity = f.Create(data.AvatarPrototype);
          var playerLink = new PlayerLink { Player = player };
          f.Add(entity, playerLink);
      }
  
+     void ISignalOnPlayerDisconnected.OnPlayerDisconnected(Frame f, PlayerRef player) {
+         foreach (var pair in f.Unsafe.GetComponentBlockIterator<PlayerLink>()) {
+             if (player == pair.Component->Player) {
+                 f.Destroy(pair.Entity);
+                 break;
+             }
+         }
+     }
  }

ゲームのフェーズ管理

作成したゲームは、上記の三つのフェーズに分かれていて、フェーズ関連のコードの定義は、QuantumのDSLから生成しています。

quantum_code - Game.qtn
enum GamePhase { Result, Ready, Action }

global {
    GamePhase GamePhase; // 現在のフェーズ
    FP RemainingTime; // 残り時間
}

signal OnEnterResult();
signal OnEnterReady();
signal OnEnterAction();

synced event OnEnterResult {}
synced event OnEnterReady {}
synced event OnEnterAction {}

各フェーズはenumで列挙体を定義して、「現在のフェーズ」と「残り時間」をグローバル変数としてglobalで定義します。また、各フェーズに遷移する際に、Quantumの他のシステムへ通知するためのシグナルsignal)と、QuantumからUnityへ通知するためのイベントevent)を定義しています。

https://doc.photonengine.com/ja-jp/quantum/v2/manual/quantum-ecs/dsl

以下のコードは、作成したゲームのフェーズ遷移周りの処理を管理するシステムの実装です。

quantum_code - GamePhaseSystem.cs
using Photon.Deterministic;

namespace Quantum.Skete;

public unsafe class GamePhaseSystem : SystemMainThread
{
    public override void OnInit(Frame f) {
        var gameSpec = f.FindAsset<GameSpec>(f.RuntimeConfig.GameSpec.Id);
        // 結果フェーズを初期フェーズに設定する
        f.Global->GamePhase = GamePhase.Result;
        f.Global->RemainingTime = gameSpec.ResultDuration;
    }

    public override void Update(Frame f) {
        var gameSpec = f.FindAsset<GameSpec>(f.RuntimeConfig.GameSpec.Id);
        switch (f.Global->GamePhase) {
        case GamePhase.Result:
            UpdateResult(f, gameSpec);
            break;
        case GamePhase.Ready:
            UpdateReady(f, gameSpec);
            break;
        case GamePhase.Action:
            UpdateAction(f, gameSpec);
            break;
        }
    }

    // 結果フェーズ中の更新処理
    private static void UpdateResult(Frame f, GameSpec gameSpec) {
        // 現在プレイヤー数をカウントする
        int currentPlayerCount = 0;
        foreach (var pair in f.Unsafe.GetComponentBlockIterator<PlayerLink>()) {
            currentPlayerCount++;
        }

        // 現在プレイヤー数が対戦開始人数以上になっていたら、残り時間のカウントダウンを進める
        // 十分な人数が揃っていない場合、残り時間をリセットする
        if (currentPlayerCount >= gameSpec.PlayersToBegin) {
            f.Global->RemainingTime -= f.DeltaTime;
        } else {
            f.Global->RemainingTime = gameSpec.ResultDuration;
        }

        // 時間切れになったら、準備フェーズに遷移する
        if (f.Global->RemainingTime <= FP._0) {
            f.Global->GamePhase = GamePhase.Ready;
            f.Global->RemainingTime = gameSpec.ReadyDuration;
            f.Signals.OnEnterReady();
            f.Events.OnEnterReady();
        }
    }

    // 準備フェーズ中の更新処理
    private static void UpdateReady(Frame f, GameSpec gameSpec) {
        f.Global->RemainingTime -= f.DeltaTime;

        // 時間切れになったら、対戦フェーズに遷移する
        if (f.Global->RemainingTime <= FP._0) {
            f.Global->GamePhase = GamePhase.Action;
            f.Global->RemainingTime = gameSpec.ActionDuration;
            f.Signals.OnEnterAction();
            f.Events.OnEnterAction();
        }
    }

    // 対戦フェーズ中の更新処理
    private static void UpdateAction(Frame f, GameSpec gameSpec) {
        f.Global->RemainingTime -= f.DeltaTime;

        // 生存プレイヤー数をカウントする
        int alivePlayerCount = 0;
        foreach (var pair in f.Unsafe.GetComponentBlockIterator<AvatarStatus>()) {
            if (pair.Component->IsAlive) { alivePlayerCount++; }
        }

        // 時間切れになるか、生存プレイヤー数が対戦終了人数以下になるか、
        // いずれかを満たしたら、結果フェーズに遷移する
        if (f.Global->RemainingTime <= FP._0 || alivePlayerCount <= gameSpec.PlayersToEnd) {
            f.Global->GamePhase = GamePhase.Result;
            f.Global->RemainingTime = gameSpec.ResultDuration;
            f.Signals.OnEnterResult();
            f.Events.OnEnterResult();
        }
    }
}

決定論的な乱数の生成

作成したゲームでは、準備フェーズ開始時に各アバターがランダムな初期位置に再配置されます。この時もし、各クライアントがそれぞれ勝手に乱数を生成して使うとすると、アバターの初期位置はクライアントごとに全く変わってしまうため、大きく同期がズレてしまうでしょう。


ゲーム開始時に各アバターをランダムな初期位置に再配置する

Quantumには、疑似乱数を決定論的に生成するRNG(Random Number Generator:乱数生成器)としてRNGSessionが用意されています。RNGSessionは「同じ乱数のシードを与えると、必ず同じ乱数列を生成する」ことが保証されているので、開発者はRNGSessionから乱数を取得するようにするだけで、Quantumのシミュレーションで安全にランダムな値を扱うことができます。

quantum_code - AvatarActivationSystem.cs
  // 半径radiusの円の内部のランダムな点を、アバターの初期位置として返す
  private FPVector2 GetRandomInitialPosition(Frame f, FP radius) {
+     var r = radius * FPMath.Sqrt(f.RNG->Next());
+     var angle = f.RNG->Next(-FP.Rad_180, FP.Rad_180);
      var x = r * FPMath.Cos(angle);
      var y = r * FPMath.Sin(angle);
      return new FPVector2(x, y);
  }

https://doc.photonengine.com/ja-jp/quantum/v2/manual/rngsession

アバターの更新(Quantum側)

以下のコードは、作成したゲームのアバターを更新するシステムの実装の抜粋です。フェーズ遷移のシグナルを受け取って、対戦フェーズ中のみアバターを更新する処理を実行するようにしているのがポイントです。アバターの更新処理自体は比較的シンプルで、移動処理を行った後にセーフゾーンとの当たり判定を取るような流れになっています。

quantum_code - AvatarActivationSystem.cs
    // 対戦フェーズに遷移したら、AvatarUpdateSystemの更新を有効にする
    void ISignalOnEnterAction.OnEnterAction(Frame f) {
        f.SystemEnable<AvatarUpdateSystem>();
    }

    // 結果フェーズに遷移したら、AvatarUpdateSystemの更新を無効にする
    void ISignalOnEnterResult.OnEnterResult(Frame f) {
        f.SystemDisable<AvatarUpdateSystem>();
    }
quantum_code - AvatarUpdateSystem.cs
using Photon.Deterministic;

namespace Quantum.Skete;

public unsafe class AvatarUpdateSystem : SystemMainThreadFilter<AvatarUpdateSystem.Filter>
{
    public struct Filter
    {
        public EntityRef Entity;
        public PlayerLink* PlayerLink;
        public Transform2D* Transform;
        public PhysicsBody2D* PhysicsBody;
        public PhysicsCollider2D* PhysicsCollider;
        public AvatarStatus* AvatarStatus;
    }

    public override bool StartEnabled => false;

    public override void Update(Frame f, ref Filter filter) {
        // 生存フラグがOFFなら、処理をスキップする
        if (!filter.AvatarStatus->IsAlive) { return; }

        // アバターの移動処理を行う
        var avatarSpec = f.FindAsset<AvatarSpec>(filter.AvatarStatus->AvatarSpec.Id);
        var input = f.GetPlayerInput(filter.PlayerLink->Player);
        filter.PhysicsBody->AddForce(avatarSpec.MoveForce * input->Direction);

        // セーフゾーンの状態を取得する
        var safeZoneFilter = f.Filter<SafeZoneStatus>();
        safeZoneFilter.NextUnsafe(out _, out var safeZoneStatus);

        // セーフゾーンの円との当たり判定を取って、触れていたら失格判定にする
        if (filter.Transform->Position.Magnitude > (safeZoneStatus->Radius - avatarSpec.Radius)) {
            var gameSpec = f.FindAsset<GameSpec>(f.RuntimeConfig.GameSpec.Id);
            // アバターの移動を停止して、当たり判定を無効にする
            filter.PhysicsBody->Enabled = false;
            filter.PhysicsCollider->Enabled = false;
            // 生存フラグをOFFにして、(結果フェーズのランキング表示用の)生存時間を記録する
            filter.AvatarStatus->IsAlive = false;
            filter.AvatarStatus->SurvivalTime = gameSpec.ActionDuration - f.Global->RemainingTime;
        }
    }
}

https://doc.photonengine.com/ja-jp/quantum/v2/manual/quantum-ecs/systems

アバターの描画(Unity側)

UnityからQuantumのゲームの状態を取得する方法は、以下の二つになります。

  1. Quantumのフレームからゲームの状態を取得する
  2. Quantumからイベントコールバックを受け取る

ここから取得したQuantum側のゲームの状態やイベントを元にして、Unity側では描画(ゲームのビューの更新)を行うことになります。

以下のコードは、作成したゲームのアバターのビューを更新する実装の抜粋です。Quantumのフレームからはアバターの生存フラグを取得して、アバター本体とプレイヤー名表示の色を更新します。また、フェーズ遷移のイベントを受け取って、軌跡パーティクルの有効/無効を切り替えています。

quantum_unity - AvatarView.cs
using Quantum;
using TMPro;
using UnityEngine;

public class AvatarView : MonoBehaviour
{
    [SerializeField] private SpriteRenderer body; // アバター本体の画像
    [SerializeField] private TextMeshPro nameLabel; // プレイヤー名の表示
    [SerializeField] private ParticleSystem trail; // 移動中の軌跡パーティクル

    private EntityView entityView;
    private bool isLocal;

    private void Start() {
        entityView = GetComponentInParent<EntityView>();

        var game = QuantumRunner.Default.Game;
        var frame = game.Frames.Predicted;
        var player = frame.Get<PlayerLink>(entityView.EntityRef).Player;
        var status = frame.Get<AvatarStatus>(entityView.EntityRef);

        // ローカルプレイヤー(自分自身)のアバターかどうかの判定を保持しておく
        isLocal = (game.PlayerIsLocal(player));
        // RuntimePlayerからプレイヤー名を取得して表示する
        nameLabel.text = frame.GetPlayerData(player).PlayerName;
        // アバター本体とプレイヤー名表示の色を更新する
        SetColor(status.IsAlive);

        // UnityのUpdate()のタイミングで安全に実行されるコールバック
        QuantumCallback.Subscribe(this, (CallbackUpdateView _) => UpdateView());
        // 対戦フェーズに遷移した時に、軌跡パーティクルを有効にする
        QuantumEvent.Subscribe(this, (EventOnEnterAction _) => trail.Play());
        // 結果フェーズに遷移した時に、軌跡パーティクルを無効にする
        QuantumEvent.Subscribe(this, (EventOnEnterResult _) => trail.Stop());
    }

    private void OnDisable() {
        QuantumCallback.UnsubscribeListener(this);
        QuantumEvent.UnsubscribeListener(this);
    }

    private void UpdateView() {
        var frame = QuantumRunner.Default.Game.Frames.Predicted;
        var status = frame.Get<AvatarStatus>(entityView.EntityRef);
        // アバター本体とプレイヤー名表示の色を更新する
        SetColor(status.IsAlive);
    }

    private void SetColor(bool isAlive) {
        if (isAlive) {
            body.color = Color.white;
            nameLabel.color = (isLocal) ? Color.yellow : Color.white;
        } else {
            body.color = Color.gray;
            nameLabel.color = Color.gray;
        }
    }
}

https://doc.photonengine.com/ja-jp/quantum/v2/manual/frames
https://doc.photonengine.com/ja-jp/quantum/v2/manual/quantum-ecs/game-events

Photon運営事務局TechBlog

Discussion