This document is about: FUSION 2
SWITCH TO

Impostor

Level 4

개요

이 샘플은 HostMode 토폴로지를 사용합니다.

Fusion Impostor는 최대 8명의 플레이어를 위한 소셜 디덕션 게임의 핵심 루프를 개발하는 방법과 Photon Voice SDK를 Fusion 프로젝트와 통합하고 처리하는 방법을 보여줍니다. Photon Voice에 대한 자세한 내용은 Voice 페이지에서 확인하세요. Fusion Impostor는 원래 Fusion 1.0을 사용하여 제작되었으나, 현재는 Fusion 2.0으로 포팅 되었으며, Fusion 1.0 버전의 대부분의 기능을 유지하고 있습니다.

이 샘플의 주요 기능은 다음과 같습니다:

  • 게임 전 로비와 게임 내에서의 음성 통신
  • 게임 전, 게임 중, 회의, 게임 후 결과를 포함한 완전 네트워크화된 게임 상태 머신 및 시스템
  • 작업 스테이션과 크루 메이트 시체와 같은 공유 상호 작용 포인트
  • 사용자 정의 가능한 게임 설정 (임포스터 수, 이동 속도, 플레이어 충돌 등)
  • 문과 같은 세계의 객체 상태 동기화
  • 다양한 크루 메이트 작업을 기반으로 한 모듈식 상호 작용 시스템
  • Photon Voice를 사용하여 다양한 음성 통신 유형 처리
  • 클라이언트가 참여할 수 있는 코드로 방 설정
  • 지역 설정, 닉네임 및 마이크 선택

기술 정보

  • 유니티 2021.3.33f1

시작하기 전에

샘플을 실행하려면:

  • PhotonEngine 관리 화면에서 Fusion AppId를 생성하고, 이를 Real Time 설정의 App Id Fusion 필드에 붙여 넣으세요(Fusion 메뉴에서 접근 가능).
  • PhotonEngine 관리 화면에서 Voice AppId를 생성하고, 이를 Real Time 설정의 App Id Voice 필드에 붙여 넣으세요.
  • 그런 다음 Launch 씬을 로드하고 Play를 누르세요.

다운로드

버전 릴리즈 일자 다운로드
2.0.1 May 30, 2024 Fusion Impostor 2.0.1 Build 560

폴더 구조

주요 스크립트 폴더 /Scripts에는 샘플의 주요 네트워킹 구현 및 네트워크 상태 머신을 포함하는 Networking 하위 폴더가 있습니다. PlayerManagers와 같은 다른 하위 폴더에는 각각 게임 플레이 동작 및 관리 로직이 포함되어 있습니다.

플레이어 레지스트리

PlayerRegistry는 방의 각 플레이어에 대한 참조를 저장하고, 하나 또는 여러 플레이어를 선택하고 작업을 수행하는 유틸리티 메서드를 제공합니다.

게임에 참여하기

사용자는 방 코드를 사용하여 방에 참여하거나 호스팅 할 수 있습니다. 사용자가 호스트를 선택한 경우 방 코드를 입력하는 것은 선택 사항입니다. 방에 들어가면 화면 하단에 참여할 수 있는 코드가 표시됩니다.

방 코드는 runner.SessionInfo.Name을 통해 접근할 수 있습니다.

NetworkStartBridgeNetworkDebugStart의 중재자 역할을 합니다. StartHost()는 특정 코드가 지정되지 않은 경우 RoomCode에서 무작위 4자 문자열을 가져옵니다.

게임 전 단계

게임 전 단계에서는 플레이어가 로비 영역의 중앙 테이블에서 자신의 색상을 선택하고, 설정에서 선호하는 마이크 장치를 선택할 수 있습니다. 호스트는 게임 설정을 사용자 정의하고 게임을 시작할 책임이 있습니다.

입력 처리

네트워크 된 입력은 PlayerInputBehaviour.cs 스크립트에서 폴링 됩니다. 또한, 여기에서 입력 차단이 수행됩니다. 추가적으로 서버 측 검사는 PlayerMovement.cs에서 입력을 실행하기 전에 수행됩니다.

키보드

  • WASD로 걷기
  • E로 상호작용
  • 숫자 패드 Enter로 게임 시작 (호스트, 게임 전 단계에서만)

마우스

  • 왼쪽 클릭으로 걷기
  • UI의 버튼을 클릭하여 상호작용

플레이어

플레이어의 동작은 세 가지 다른 컴포넌트로 정의됩니다:

  • PlayerObject: 이 객체가 연결된 PlayerRef에 대한 참조를 포함하며, 룸 내에서의 인덱스, 닉네임, 선택한 색상을 포함합니다.
  • PlayerMovement: 플레이어의 이동 및 입력을 담당합니다. 또한 IsDead, IsSuspect, EmergencyMeetingUses 속성과같이 게임 플레이에 필수적인 데이터를 포함합니다.
  • PlayerData: 플레이어의 시각적 컴포넌트입니다. 주로 재료를 처리하고, 애니메이터 속성을 설정하며, 닉네임 UI를 인스턴스화합니다.

상호작용 가능한 객체

  • 색상 키오스크: 게임 전 방의 중앙 테이블에 위치. 플레이어는 다른 플레이어가 선택하지 않은 12개의 사전 설정 색상 중에서 선택할 수 있습니다.
  • 설정 키오스크: 게임 전 방의 상단에 위치. 호스트는 여기에서 게임 설정을 선택하고 게임을 시작할 수 있습니다.
  • 비상 버튼: 라운드당 제한된 횟수로 눌러 회의를 소집할 수 있습니다.
  • 작업: 5개의 고유한 작업 미니 게임을 특징으로 하는 14개의 작업 스테이션이 맵 곳곳에 배치되어 크루 메이트가 완료할 수 있습니다.
  • 시체: 살해된 플레이어의 시체는 크루 메이트가 회의를 소집하기 위해 보고할 수 있으며, 임포스터는 자신의 흔적을 지우기 위해 이를 보고할 수 있습니다.

작업

작업 스테이션은 맵 곳곳에 배치되어 있습니다. 크루 메이트는 범위 내에 있을 때 이들과 상호작용할 수 있습니다.

  • 온도 조절기 (TemperatureTask.cs): 위아래 화살표를 눌러 두 숫자를 같게 만듭니다.
  • 슬라이더 (SlidersTask.cs): 각 슬라이더를 빨간 윤곽선과 맞추어 드래그합니다. 올바른 위치에 놓이면 잠깁니다.
  • 패턴 맞추기 (PatternMatchTask.cs): 오른쪽 패널의 버튼을 눌러 왼쪽 패널에 깜박이는 조명 시퀀스와 일치시킵니다.
  • 숫자 순서 (NumberSequenceTask.cs): 각 숫자를 오름차순으로 누릅니다 (1-8).
  • 파일 다운로드 (DownloadTask.cs): 다운로드 버튼을 누르고 막대가 채워질 때까지 기다려 작업을 완료합니다.

음성

Fusion Impostor에서 Voice 2 통합을 위해 Photon Voice 2에서 제공하는 두 가지 스크립트를 사용합니다:

  • FusionVoiceNetworkPrototypeRunner 프리팹에 추가됩니다.
  • VoiceNetworkObjectPlayer 프리팹에 사용되며, 해당 프리팹의 자식으로 Speaker가 있습니다.

마이그레이션 노트

앞서 언급했듯이, Fusion Impostor는 Fusion 1.0에서 Fusion 2.0으로 포팅 되었습니다. Fusion 1.0에서 Fusion 2.0으로의 마이그레이션에 대한 자세한 내용은 여기에서 확인할 수 있습니다. 포팅 과정에서 다음과 같은 변경 사항이 있었습니다.

FSM

이 샘플의 Fusion 1.0 버전은 게임 상태를 관리하기 위해 커스텀 유한 상태 머신을 사용했습니다. 이 샘플은 Fusion 2.0의 FSM 애드온을 사용하여 다양한 게임 상태와 그에 수반되는 스크립트를 더 깔끔하게 조직하는 것을 목표로 합니다. 메인 게임 오브젝트의 계층 구조에는 다음이 포함됩니다:

프로젝트의 gamestate 계층 구조
프로젝트의 GameState 계층 구조

각 상태는 상태 머신 시스템에서 사용되는 StateMachineNetworkBehaviourStateBehaviour를 상속합니다. 이러한 상태는 네트워크 측에서 처리되는 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를 실행하여 투표 결과가 제대로 표시되도록 합니다.

KCC 및 지연 보상

이 샘플의 원본 버전은 Fusion 1.0의 KCC를 사용했습니다. 이 버전은 Fusion 2.0의 고급 KCC와 유사합니다. 그러나 Fusion 2.0의 Simple KCC는 이 게임에 충분합니다. 가장 큰 변화는 Simple KCC를 사용함으로써 고급 KCC에 있는 OnCollisionEnter 및 OnCollisionExit이 더 이상 존재하지 않는다는 점입니다. 이를 해결하기 위해 상호 작용 가능 객체 및 다른 플레이어에 대한 충돌 검사는 지연 보상을 사용하여 PlayerMovementFixedUpdateNetwork 메서드로 이동되었습니다. 특히, 이동 중 플레이어가 임포스터의 킬 범위에서 벗어날 수 있기 때문에, 특히 연결 상태가 좋지 않은 게임에서 지연 보상이 통합되어 이 충돌 감지를 더 정확하게 만들었습니다. 지연 보상에 대한 자세한 내용은 여기에서 확인할 수 있습니다. 다음 코드는 업데이트된 KCC와 지연 보상이 PlayerMovement의 Fixed Update Network 메서드에서 어떻게 작동하는지 보여줍니다:

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 게임 객체에 추가되었으며, 해당 Collider 컴포넌트는 제거되었습니다.
플레이어 캐릭터의 히트박스 컴포넌트
플레이어 프리팹의 `Kill Radius` 게임 객체에 있는 히트 박스 컴포넌트.

스크립트 및 프로토 타이핑

Fusion 1.0에 존재하는 Assets/Fusion/Scripts 폴더는 다양한 프로토 타이핑 도구를 포함하고 있으며, Fusion 2.0에는 더 이상 존재하지 않습니다. 이러한 도구 중 다수는 중복되거나, 과도하게 복잡하거나, 불필요했기 때문에 이 샘플에서는 제거되었습니다. 유일하게 보존된 스크립트는 InputBehaviourPrototype으로, 이는 Assets/Scripts/Networking으로 이동되었습니다. 새로 생성된 유일한 클래스는 플레이어가 참가할 때 NetworkObject를 스폰하고 떠날 때 디스폰하는 역할을 하는 NetworkRunner 프리팹에 부착된 SimulationBehaviourPlayerSpawner입니다:

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