This document is about: FUSION 2
SWITCH TO

Impostor

Level 4

概要

このサンプルはHostModeトポロジーを使用しています。

Fusion Impostor は、最大8人のプレイヤー向けのソーシャルディダクションゲームのコアループを開発する方法と、FusionプロジェクトにおけるPhoton Voice SDKとの通信の統合と処理方法を示しています。Photon Voiceに関する詳細は、マニュアルのボイスページをご覧ください。
Fusion Impostorは、元々Fusion 1.0を使用して作成されましたが、Fusion 2.0に移植されており、Fusion 1.0バージョンの大部分の機能を保持しています。

このサンプルの主な特長は以下のとおりです:

  • プレゲームロビーおよびゲーム内での音声通信
  • プレゲーム、プレイ、会議、ポストゲームの結果を含む完全なネットワーク対応ゲームステートマシンとシステム
  • タスクステーションやクルーメイトの遺体などの共有インタラクションポイント
  • カスタマイズ可能なゲーム設定(インポスターの数、移動速度、プレイヤーの衝突など)
  • 扉のようなワールド内のオブジェクトの同期された状態
  • モジュラーインタラクションシステムに基づいたさまざまなクルーメイトタスク
  • Photon Voiceを使用してさまざまな音声通信タイプを処理
  • クライアントが参加するためのコードを使用してホストとして部屋を設定
  • リージョン設定、ニックネーム、マイクの選択

技術情報

  • Unity 2021.3.33f1

始める前に

サンプルを実行するには:

  • PhotonEngine DashboardでFusion AppIdを作成し、それをFusionメニューからアクセスできるリアルタイム設定の「App Id Fusion」フィールドに貼り付けます。
  • PhotonEngine DashboardでVoice AppIdを作成し、それをリアルタイム設定の「App Id Voice」フィールドに貼り付けます。
  • その後、Launchシーンを読み込み、Playを押します。

ダウンロード

バージョン リリース日 ダウンロード
2.0.1 May 30, 2024 Fusion Impostor 2.0.1 Build 560

フォルダ構造

主なScriptsフォルダ/Scriptsには、サンプル内の主要なネットワーキング実装とネットワーク対応ステートマシンを含むNetworkingというサブフォルダがあります。他のサブフォルダには、それぞれゲームプレイの動作や管理に関するロジックを含むPlayerManagersがあります。

PlayerRegistry

PlayerRegistryは、部屋内の各プレイヤーへの参照を保存し、1人または多数のプレイヤーに対して選択やアクションを実行するためのユーティリティメソッドを提供します。

ゲームへの参加

ユーザーは、ルームコードを使用してルームに参加するか、ホストすることができます。ホストする場合、ルームコードの入力はオプションです。ルームに入ると、参加するためのコードが画面の下部に表示されます。

ルームコードには、runner.SessionInfo.Nameを介してアクセスされます。

NetworkStartBridgeNetworkDebugStartの仲介役として機能します。StartHost()は、特定のコードが指定されていない場合、RoomCodeからランダムな4文字の文字列を取得します。

プレゲーム

プレゲームフェーズでは、プレイヤーはロビーエリアの中央にあるテーブルから自分の色を選択したり、設定から好みのマイクデバイスを選択したりできます。ホストはゲーム設定をカスタマイズすることができ、ゲームを開始する責任があります。

入力の処理

ネットワーク対応の入力はPlayerInputBehaviour.csスクリプトでポーリングされます。ここでは入力のブロッキングも行われます。さらに、入力を実行する前にPlayerMovement.csでサーバー側のチェックが行われます。

キーボード

  • 移動にはWASDを使用
  • インタラクトにはEを使用
  • ゲームを開始するにはテンキーのEnterを使用(ホストの場合、プレゲーム中のみ)

マウス

  • 移動には左クリックを使用
  • インタラクションにはUIのボタンをクリック

プレイヤー

プレイヤーの動作は、3つの異なるコンポーネントによって定義されます:

  • PlayerObject: このオブジェクトが関連付けられているPlayerRefへの参照を保持し、部屋内でのプレイヤーのインデックス、ニックネーム、選択された色を含みます。
  • PlayerMovement: プレイヤーの移動と入力を担当します。また、ゲームプレイに必要なデータとメソッドを保持しており、特にIsDeadIsSuspectEmergencyMeetingUsesプロパティがあります。
  • PlayerData: プレイヤーの視覚コンポーネントです。主にマテリアルを扱い、アニメーターのプロパティを設定し、ニックネームUIをインスタンス化します。

インタラクタブル

  • カラーキオスク:プレゲームルームの中央テーブルに位置しています。プレイヤーは、他のプレイヤーによってすでに選ばれていない12のプリセットカラーの中から選択できます。
  • 設定キオスク:プレゲームルームの上部に位置しており、ホストはここでゲーム設定を選択し、ゲームを開始できます。
  • 緊急ボタン:緊急ボタンは、1ラウンドあたり限られた回数だけ押すことができ、会議を呼び出します。
  • タスク:マップ全体に14のタスクステーションが配置されており、5つのユニークなタスクミニゲームがクルーメイトが完了するために用意されています。
  • 遺体:殺害されたプレイヤーの遺体は、クルーメイトや自らの痕跡を隠そうとするインポスターが、無料で会議を呼び出すために報告できます。

タスク

タスクステーションはマップ全体に存在します。クルーメイトは、範囲内にいるときにこれらとインタラクトできます。

  • サーモスタット (TemperatureTask.cs):上矢印と下矢印を押して2つの数字を等しくします。
  • スライダー (SlidersTask.cs):各スライダーを赤いアウトラインに合わせてドラッグします。正しく配置されるとロックされます。
  • パターンマッチ (PatternMatchTask.cs):左のパネルで点滅するライトのシーケンスに合わせて右のパネルのボタンを押します。
  • 数字シーケンス (NumberSequenceTask.cs):各数字を昇順に押します(1-8)。
  • ファイルダウンロード (DownloadTask.cs):ダウンロードボタンを押して、バーが満たされるのを待つことでタスクを完了します。

ボイス

Fusion ImpostorにおけるVoice 2統合では、Photon Voice 2が提供する2つのスクリプトが使用されています:

  • FusionVoiceNetworkPrototypeRunnerプレハブに追加されています。
  • VoiceNetworkObjectPlayerプレハブで使用されており、指定されたプレハブの子としてSpeakerも含まれています。

移行ノート

既に述べたように、Fusion ImpostorはFusion 1.0からFusion 2.0に移植されました。Fusion 1.0からFusion 2.0への移行に関する詳細はこちらで読むことができます。以下は、その移植プロセス中に行われた変更のいくつかです。

FSM

このサンプルのFusion 1.0バージョンでは、ゲームステートを管理するためにカスタムの有限状態機械(FSM)が使用されていました。このサンプルでは、Fusion 2.0のFSMアドオンを使用して、異なるゲームステートとその関連スクリプトをよりクリーンに整理することを目指しています。Main GameObjectの階層には、次のものが含まれるようになりました:

The GameState hierarchy in the project
The GameState hierarchy in the project

各ステートは、StateMachineシステムによって使用されるNetworkBehaviourであるStateBehaviourを継承します。これには、ネットワーク側で処理されるOnEnterStateOnExitStateなどの関数が含まれています。また、ネットワーク対応のゲームプレイには影響しないゲーム内のレンダリング変更のために使用されるOnEnterStateRenderOnExitStateRenderなどの他のメソッドも含まれています。以下は、このサンプルのVotingResultsStateBehaviourを使用した例です:

C#

/// <summary>
/// State for handles the game once voting has finished
/// </summary>
public class VotingResultsStateBehaviour : StateBehaviour
{
    /// <summary>
    /// Which state will we go to next
    /// </summary>
    private StateBehaviour nextState;

    /// <summary>
    /// How long we will wait before going to the next state in seconds.
    /// </summary>
    private float nextStateDelay;

    protected override void OnEnterState()
    {
        // If a player has been ejected...
        if (GameManager.Instance.VoteResult is PlayerObject pObj)
        {
            pObj.Controller.IsDead = true;
            pObj.Controller.Server_UpdateDeadState();

            int numCrew = PlayerRegistry.CountWhere(p => !p.Controller.IsDead && p.Controller.IsSuspect == false);
            int numSus = PlayerRegistry.CountWhere(p => !p.Controller.IsDead && p.Controller.IsSuspect == true);

            if (numCrew <= numSus)
            {   // impostors win if they can't be outvoted in a meeting
                WinStateBehaviour winState = Machine.GetState<WinStateBehaviour>();
                winState.crewWin = false;
                nextState = winState;
            }
            else if (numSus == 0)
            {   // crew wins if all impostors have been ejected
                WinStateBehaviour winState = Machine.GetState<WinStateBehaviour>();
                winState.crewWin = true;
                nextState = winState;
            }
            else
            {   // return to play if the game isn't over
                nextState = Machine.GetState<PlayStateBehaviour>();
            }

            nextStateDelay = 3f;
        }
        else
        {   // return to play if there was nobody ejected
            nextState = Machine.GetState<PlayStateBehaviour>();
            nextStateDelay = 2f;
        }
    }
    protected override void OnEnterStateRender()
    {
        GameManager.im.gameUI.EjectOverlay(GameManager.Instance.VoteResult);
    }

    protected override void OnFixedUpdate()
    {
        if (Machine.StateTime > nextStateDelay)
        {
            Machine.ForceActivateState(nextState);
        }
    }
}

これにより、ステートに入ったときにOnEnterStateはステート権限を持つプレイヤー、つまりホストのみによって呼び出され、ゲームプレイに影響を与えるメソッドが実行されます。しかし、すべてのクライアントはOnEnterStateRenderを実行するため、ゲームのUIは投票の結果を正しく表示します。

KCCとラグ補償

このサンプルの元のバージョンは、Fusion 1.0のKCCを使用していました。このバージョンは、Fusion 2.0のAdvanced KCCに似ていますが、Fusion 2.0のSimple KCCはこのゲームには十分です。最大の変更点は、Simple KCCを使用することで、Advanced KCCに存在するOnCollisionEnterおよびOnCollisionExitがもはや存在しないことです。この問題を解決するために、インタラクタブルや他のプレイヤーとの衝突チェックは、PlayerMovementFixedUpdateNetworkメソッドに移動し、ラグ補償を使用して行われます。プレイヤーは特に接続が不良なゲーム中に移動する際に、インポスターのキル範囲外に落ちる可能性があるため、ラグ補償がこのサンプルに統合され、衝突検出がより正確になりました。ラグ補償についての詳細はこちらで読むことができます。以下のコードは、PlayerMovementのFixed Update Networkメソッド内で、更新されたKCCとラグ補償がどのように機能するかを示しています:

C#

public override void FixedUpdateNetwork()
{
    bool hasInput = GetInput(out PlayerInput input);

    if (hasInput && input.IsDown(PlayerInputBehaviour.BUTTON_START_GAME))
    {
        GameManager.Instance.Server_StartGame();
    }

    Vector3 direction = default;
    bool canMoveOrUseInteractables = activeInteractable == null && GameManager.Instance.MeetingScreenActive == false && GameManager.Instance.VotingScreenActive == false && hasInput;

    if (canMoveOrUseInteractables)
    {
        // BUTTON_WALK is representing left mouse button
        if (input.IsDown(PlayerInputBehaviour.BUTTON_WALK))
        {
            direction = new Vector3(
                Mathf.Cos((float)input.Yaw * Mathf.Deg2Rad),
                0,
                Mathf.Sin((float)input.Yaw * Mathf.Deg2Rad)
            );
        }
        else
        {
            if (input.IsDown(PlayerInputBehaviour.BUTTON_FORWARD))
            {
                direction += TransformLocal ? transform.forward : Vector3.forward;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_BACKWARD))
            {
                direction -= TransformLocal ? transform.forward : Vector3.forward;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_LEFT))
            {
                direction -= TransformLocal ? transform.right : Vector3.right;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_RIGHT))
            {
                direction += TransformLocal ? transform.right : Vector3.right;
            }

            direction = direction.normalized;
        }
    }

    simpleCC.Move(direction * Speed);

    if (direction != Vector3.zero)
    {
        Quaternion targetQ = Quaternion.AngleAxis(Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg - 90, Vector3.down);
        cc.SetLookRotation(Quaternion.RotateTowards(transform.rotation, targetQ, lookTurnRate * 360 * Runner.DeltaTime));
    }

    // Performs an overlap sphere test to see if the player is close enough to interactables
    int lagHit = Runner.LagCompensation.OverlapSphere(transform.position, cc.Settings.Radius, Object.InputAuthority, lagCompensatedHits, _interactableLayerMask,
        options: HitOptions.IncludePhysX);

    // Can the player report, kill, or use the interactable.
    bool canReport = false, canKill = false, canUse = false;

    // The lists of nearby players and interactables are cleared with every check.
    nearbyInteractables.Clear();
    nearbyPlayers.Clear();

    // Iterates through the results
    for (int i = 0; i < lagHit; i++)
    {
        if (lagCompensatedHits[i].Hitbox is Hitbox hb)
        {
            // We don't bother tryingt to find nearby players if we are the suspect.
            if (IsSuspect && !hb.transform.IsChildOf(transform) && hb.gameObject.layer == _playerRadiusLayerMask && hb.GetComponentInParent<PlayerObject>() is PlayerObject player)
            {
                nearbyPlayers.Add(player);
                canKill = true;
            }
            continue;
        }

        GameObject hitGameObject = lagCompensatedHits[i].Collider.gameObject;

        if (hitGameObject.TryGetComponent<Interactable>(out var hitInteractable))
        {
            if (!nearbyInteractables.Contains(hitInteractable))
                nearbyInteractables.Add(hitInteractable);

            if (hitInteractable is DeadPlayer)
                canReport = true;
            else
                canUse = hitInteractable.CanInteract(this);
        }
    }

    if (HasInputAuthority)
    {
        GameManager.im.gameUI.reportButton.interactable = canReport;
        GameManager.im.gameUI.killButton.interactable = canKill;
        GameManager.im.gameUI.useButton.interactable = canUse;
    }

    if (!canMoveOrUseInteractables)
        return;

    actionPerformed = false;

    // When pressing the interact button, there's no clear way to know what action is being done, so this order is used.
    if (input.IsDown(PlayerInputBehaviour.BUTTON_REPORT) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryToReportDeadPlayer();

    if (input.IsDown(PlayerInputBehaviour.BUTTON_USE) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryToUseStation();

    if (input.IsDown(PlayerInputBehaviour.BUTTON_KILL) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryKill();
}

さらに、ラグ補償に対応するために以下の変更が行われました:

  • Hitbox ManagerコンポーネントがメインのNetworkRunnerプレハブに追加されました。
  • Hitbox RootコンポーネントがPlayerプレハブのルートに追加されました。
  • HitboxコンポーネントがPlayerプレハブのKill Radius GameObjectに追加され、そのColliderコンポーネントが削除されました。
The Hitbox component on the Player character
The Hitbox component on the Kill Radius GameObject of the Player prefab.

スクリプトとプロトタイピング

Fusion 1.0に存在し、PlayerSpawnerPrototypeなどのプロトタイピング用ツールを含むAssets/Fusion/Scriptsフォルダは、Fusion 2.0には存在しなくなりました。このディレクトリ内の多くのスクリプトはアップグレード可能でしたが、多くは冗長で、過度に複雑で、または不要なものであったため、これらのアイテムはこのサンプルから削除されました。保存された唯一のスクリプトはInputBehaviourPrototypeで、Assets/Scripts/Networkingに移動されました。新たに作成されたもう1つのクラスはPlayerSpawnerで、これはプレイヤーが参加したときにNetworkObjectを生成する役割を持つ、メインのNetworkRunnerプレハブに付随するSimulationBehaviourです。

C#

public class PlayerSpawner : SimulationBehaviour, IPlayerJoined
{
    public NetworkObject playerObject;

    public void PlayerJoined(PlayerRef player)
    {
        if (Runner.IsServer)
        {
            NetworkObject spawnedPlayer = Runner.Spawn(playerObject, position: GameManager.Instance.preGameMapData.GetSpawnPosition(player.AsIndex), inputAuthority: player);
        }
    }

    public void PlayerLeft(PlayerRef player)
    {
        if (Runner.IsServer)
        {
            PlayerObject leftPlayer = PlayerRegistry.GetPlayer(player);
            if (leftPlayer != null)
            {
                Runner.Despawn(leftPlayer.Object);
            }
        }
    }
}

プレイヤーが参加すると、このスクリプトはサーバーが新しいインスタンスのplayerObjectを生成することを確実にし、適切な位置とinputAuthorityを持つようにします。プレイヤーが離れると、PlayerRegistryは離脱したプレイヤーのPlayerRefに関連付けられたPlayerObjectを返し、そのプレイヤーをデスポーンさせます。

Back to top