기술 설계
소개
이 문서는 해적 어드벤처 샘플의 기술 설계에 대한 다양한 측면을 자세히 설명합니다. 여기에는 메인 메뉴부터 NavMesh 에이전트 사용 방식까지 다루며, 더욱 심층적인 코드 설명은 샘플 유니티 프로젝트에서 찾아볼 수 있습니다.
공유 모드 개념: 상태 권한 및 RPC
서버 또는 클라이언트 호스트 토폴로지와 달리, 공유 모드에서는 특정 객체에 대해 플레이어가 상태 권한을 가질 수 있습니다. 따라서 상태 권한이 없는 객체에 영향을 미치려면 다음과 같은 방법이 필요합니다:
- RPC를 상태 권한이 있는 클라이언트로 보냅니다.
NetworkObject.RequestAuthority
를 호출하여 상태 권한을 요청합니다.
두 번째 방법을 사용하는 경우, NetworkObject
가 Allow State Authority Override가 활성화되어 있는지 확인해야 합니다. 그렇지 않으면, 상태 권한을 가진 플레이어가 먼저 NetworkObject.ReleaseAuthority
를 호출해야 합니다.
다음은 이 샘플에서 NetworkRunner
프리팹에 포함된 PirateAdventureSimulationBehaviour
의 OnPlayerLeft
메소드 코드입니다:
C#
public void OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{
if (!runner.IsSharedModeMasterClient)
return;
var objects = runner.GetAllNetworkObjects();
foreach (var obj in objects)
{
// If the state authority cannot be overridden, it is skipped.
if ((obj.Flags & NetworkObjectFlags.AllowStateAuthorityOverride) != NetworkObjectFlags.AllowStateAuthorityOverride ||
(obj.Flags & NetworkObjectFlags.MasterClientObject) == NetworkObjectFlags.MasterClientObject ||
(obj.Flags & NetworkObjectFlags.DestroyWhenStateAuthorityLeaves) == NetworkObjectFlags.DestroyWhenStateAuthorityLeaves)
continue;
// If the state authority of the object is equal to the player who left, we transfer ownership to the shared mode master client.
if (obj.StateAuthority == player)
obj.RequestStateAuthority();
}
}
로컬 및 원격 반응 처리
플레이어가 연결할 수 있는 최적의 지역을 선택하지 않았기 때문에 연결 상태가 좋지 않은 경우, 객체의 상태 권한에 RPC를 보내고 상태 권한에 이벤트를 확인한 다음 원래 플레이어가 네트워크 속성
의 변경 사항을 감지하도록 하는 데 걸리는 시간이 지연되는 것처럼 보일 수 있습니다. 이를 최소화하기 위해 이 샘플의 많은 개체는 즉각적인 국소 반응을 갖는다는 개념을 활용합니다. 예를 들어, 플레이어가 분홍색 보석을 집어 들었을 때 재생 중인 소리와 입자 효과로 인해 즉시 보석을 집어 든 것처럼 보이지만, 이 컬렉션에 대한 확인이 즉시 이루어지지는 않았습니다. 이는 PickUp.cs
의 다음 코드에서 설명할 수 있습니다:
C#
public void OnPickUpLocal(Player player)
{
collectedFX.PlayFX();
shrinkTransition?.BeginTransition(true);
RPC_Collect(player);
}
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
public void RPC_Collect(Player collector)
{
if (Collected)
return;
collector.RPC_Reward(_rewardValue, _healthRegenValue);
Collected = true;
shrinkTransition?.BeginTransition(false);
}
void OnCollectedChanged()
{
if (Collected)
{
collectedFX.PlayFX();
}
}
여기서 collectedFX.PlayFX()
는 플레이어가 객체를 집어 든 즉시 호출되며, 이는 시각적 및 음향 효과를 트리거 합니다. 그런 다음 RPC를 주 당국에 전송하고, 주정부 당국은 이를 확인해야 합니다. NetworkProperty
인 Collected
가 사실인지 확인합니다. 그렇다면 이 항목은 이미 수집된 것입니다. 그렇다면 플레이어의 RPC는 무시되며, 보통 두 플레이어가 같은 시간에 같은 항목과 충돌하는 경우에 이런 일이 발생합니다. 오브젝트가 수집되지 않은 경우 원본 RPC를 보낸 플레이어에게 RPC가 전송되며 Collected
는 참으로 설정됩니다. Collected
가 변경되었을 때 트리거 되지만 CollectedFX.PlayFX
는 이를 다시 플레이하지 못하도록 시도합니다.
멀티피어 모드
멀티피어 모드는 이 샘플에서 새로운 빌드를 생성하거나 여러 인스턴스를 실행할 필요 없이 유니티 에디터 내에서 여러 플레이어를 빠르게 테스트하기 위해 사용됩니다. 멀티피어 모드의 설정 및 구체적인 내용은 여기에서 확인할 수 있습니다. 이 모드는 매우 유용한 테스트 방법이지만 몇 가지 고유한 개념을 염두에 두어야 합니다.
예를 들어, 시뮬레이션 내에서 모든 플레이어 아바타가 생성됩니다. 두 개의 클라이언트를 실행할 경우, 실제로 각 클라이언트에 두 개씩 총 네 개의 아바타가 생성됩니다. 이러한 이유로 OnTriggerEnter
와 같은 유니티 기반 메소드를 사용할 때는 충돌체(Collider
)가 같은 시뮬레이션에 속한 NetworkObjects
에 해당하는지 확인하는 것이 중요합니다. 이는 두 객체의 NetworkRunner
를 비교하여 수행할 수 있습니다. 이 검사가 이루어지지 않으면 잘못된 긍정(false positive)이 발생하여 멀티피어 모드 테스트 시 혼란을 초래할 수 있습니다. 다음은 Switch.cs
에서 사용된 OnTriggerEnter
메소드의 예시입니다:
C#
private void OnTriggerEnter(Collider other)
{
// We ignore if the runner is not visible
if (!Runner.GetVisible())
return;
var player = other.GetComponentInParent<Player>();
// This check is to make sure the switch doesn't react to the wrong player when in multi-peer mode.
if (player == null || player.Object.Runner != Object.Runner)
return;
if (localInRangePlayers.Contains(player.Object))
return;
else
localInRangePlayers.Add(player.Object);
Triggered = localInRangePlayers.Count > 0;
}
또한, 시각적으로 렌더링 되는 NetworkObjects
에는 OnEnableSingleRunner
컴포넌트가 있어야 합니다. 이 샘플에서는 Preferred Runner
값이 Client
로 설정되어, 첫 번째로 보이는 클라이언트에서 해당 객체가 활성화됩니다. 그런 다음 NetworkRunner
의 가시성에 따라 전환될 컴포넌트 목록이 지정됩니다. 이 목록에는 주로 Renderers
가 포함되지만, AudioSources
와 Lights
같은 다른 컴포넌트도 포함될 수 있습니다. 이 목록에 null 객체가 없어야 하며, 그렇지 않으면 게임이 제대로 실행되지 않습니다. 또한, 이 기능이 작동하려면 NetworkRunner
프리팹에 RunnerEnableVisibility
컴포넌트가 반드시 있어야 합니다.
멀티피어 싱글톤
게임 내 로컬 플레이어가 스폰될 때 일부 게임에서는 해당 참조를 정적 변수로 저장합니다. 일반적으로 이 방식은 잘 작동하지만, 멀티피어 모드에서는 각 NetworkRunner
가 마지막으로 스폰 된 플레이어로 이 값을 설정하므로 정확하지 않을 수 있습니다. 이 샘플에서는 이 문제를 해결하기 위해 정적 딕셔너리를 사용하며, 키는 객체가 속한 NetworkRunner
로 구성됩니다. 아래 코드는 Player.cs
의 Spawned
메소드에서 가져왔습니다:
C#
// Only the local player will have state authority over a player, so the local player dictionary will be populated with said player.
// A dictionary is used to account for multi-peer mode.
if (HasStateAuthority)
{
if (LocalPlayerDictionary == null)
LocalPlayerDictionary = new Dictionary<NetworkRunner, Player>()
{
{Runner,this },
};
else
LocalPlayerDictionary.Add(Runner, this);
}
메인 메뉴
해적 어드벤처의 메인 메뉴는 매우 간단하게 설계되어 있으며, 플레이어가 Start
버튼을 클릭하면 첫 번째로 열린 방에 바로 참여할 수 있습니다.
지역 선택
Region Select
드롭 다운을 통해 여러 지역을 테스트할 수 있습니다. 기본적으로 다음 지역이 제공됩니다:
- 최고 지역
- 유럽,
eu
- US, 동부,
us
- US, 서부,
usw
- 남 아메리카,
sa
다른 지역은 PirateAdventureMainMenu
의 Region Info
배열에 추가할 수 있습니다. Region Display
는 드롭 다운에 표시될 이름을 나타내며, Region Code
는 연결할 지역의 ID를 의미합니다. 빈 지역 코드는 Fusion이 자동으로 최적의 지역을 찾도록 합니다. 이용 가능한 지역, 코드 및 개요는 여기에서 확인할 수 있습니다.
세션 시작 (Starting the Session)
메인 메뉴의 Start
버튼을 클릭하면 애플리케이션은 StartGameUtility.AddPlayer
를 호출하여 세션 생성을 시작합니다. 아래는 해당 프로세스의 간략한 설명입니다:
- 새
NetworkRunner
인스턴스가 생성됩니다. - 새 세션일 경우,
PhotonAppSettings.Global.AppSettings.FixedRegion
이 선택된 지역으로 설정됩니다. 이 설정은Assets/Photon/Fusion/Resources/PhotonAppSettings.asset
의ScriptableObject
에 접근합니다. Fusion.StartGameArgs
구조체를 생성하여GameMode
를GameMode.Shared
로 설정하며, 이는 토폴로지에 따라 선택됩니다. 또한,PlayerCount
는StartGameUtility.cs
의MAX_PLAYER_COUNT
값으로 설정됩니다.- 게임 시작 결과를 기다린 후 세션에 성공적으로 참가하고 플레이어가
SharedMasterClient
인 경우,NetworkRunner.LoadScene
을 사용해 게임 플레이 장면이 로드됩니다.
TaskManager.Delay
WebGL 빌드 및 스레딩 제한으로 인해, NetworkSceneAsyncOp
완료를 기다릴 때 await
대신 **TaskManager.Delay
**를 사용합니다. 아래 코드는 StartGameUtility
의 AddPlayer
메소드에서 가져왔습니다:
C#
if (newRunner.IsSharedModeMasterClient)
{
NetworkSceneAsyncOp sceneLoad = newRunner.LoadScene(sceneToLoad, UnityEngine.SceneManagement.LoadSceneMode.Single);
// We wait until the scene is done loading.
while (!sceneLoad.IsDone)
{
// TaskManager.Delay is to prevent issues with threading and WebGL.
await TaskManager.Delay(1000);
}
}
else if (NetworkRunner.Instances.Count > 1)
{
newRunner.ProvideInput = false;
newRunner.SetVisible(false);
}
TaskManager.Delay
는 내부적으로 NetworkRunner.StartGame
를 사용하는 WebGL 문제를 해결하기 위한 Fusion 전용 솔루션입니다. 다음과 같은 코드가 WebGL에서 작동하지 않는 이유입니다:
C#
await NetworkRunner.StartGame
위 코드는 WebGL에서 동작하지만,
C#
NetworkSceneAsyncOp sceneLoad = newRunner.LoadScene(sceneToLoad, UnityEngine.SceneManagement.LoadSceneMode.Single);
await sceneLoad;
위 코드는 동작하지 않을 것입니다. 대신 다음과 같은 코드가 WebGL에서 작동합니다:
NetworkRunner 프리팹
StartGameUtility
에 의해 인스턴스화된 프리팹에는 다음과 같은 추가 컴포넌트가 포함됩니다:
Runner Enable Visibility
: 멀티피어 모드에서 어떤 컴포넌트를 활성화할지 결정합니다.Pirate Adventure Simulation Behaviour
:INetworkRunnerCallbacks
인터페이스를 사용하여 플레이어가 참여할 때 새로운 플레이어를 스폰 하는 등의 콜백을 트리거 합니다.Runner Simulate Physics 3D
: 물리 엔진을 정상적으로 작동시키기 위해 필수적인 물리 애드온입니다.
Pirate Adventure Simulation Behaviour
는 다음과 같은 INetworkRunnerCallbacks
메소드를 사용합니다:
- OnPlayerJoined: 새 플레이어를 스폰 하며, 로컬 플레이어가 해당 객체를 인스턴스화하도록 합니다. 이를 통해 로컬 플레이어가 상태 권한을 가지게 됩니다.
- OnPlayerLeft: 나간 플레이어가 상태 권한을 가지고 있던 객체의 권한을 SharedModeMasterClient에게 이전합니다.
- OnShutdown: 게임 관련 싱글톤을 정리하고 플레이어를 메인 메뉴로 돌려보냅니다.
공유 토폴로지에서는 다른 토폴로지와 달리 서버에 입력을 보낼 필요가 없으므로 OnInput
메소드는 필요하지 않습니다. 대신 상태 권한을 가진 플레이어가 FixedUpdateNetwork
메소드 내에서 입력을 처리합니다.
플레이어
Player
NetworkBehaviour
는 세션 내에서 각 플레이어가 제어하는 주요 객체입니다. 공유 모드에서는 각 플레이어가 자신의 Player
객체에 대한 상태 권한을 가지며, 입력과 로컬 충돌을 처리합니다. 이 플레이어는 FSM 애드온을 사용해 현재 표시되는 상태와 애니메이션을 결정합니다.
네트워크 속성
Player
가 사용하는 NetworkProperty
중 일부는 다음과 같이 플레이어 간에 공유됩니다:
HueValue
: 플레이어의 색상을 나타내며,NetworkObject.StateAuthority.AsIndex
로 결정됩니다.Level
: 플레이어의 레벨로, 최대 체력을 결정하며 플레이어가 사용할 수 있는 공격 콤보를 설정합니다. 레벨이 변경되면 "레벨 업" 애니메이션이 재생됩니다.DamagedTimer
: 플레이어가 손상을 입었을 때와 지속 시간을 결정하는 타이머입니다. 활성화되면 플레이어가 깜박이는 효과를 제어합니다.
Money
와 Health
같은 속성도 네트워크에 연결되어 있지만 다른 플레이어들에게 직접 시각화되지는 않습니다. 하지만 이러한 요소를 네트워크에 연결하는 것은 일반적인 관행으로, 재접속 시 플레이어의 상태를 복원하는 데 사용될 수 있습니다. 이 샘플에서는 해당 기능을 구현하지 않았지만, 떠난 플레이어의 상태를 보존하여 다시 연결될 때 해당 값을 재적용하는 것이 가능합니다.
충돌 감지
이 샘플에서 핑크색 보석과 파란 물고기 같은 아이템은 플레이어가 충돌할 때 수집됩니다. 이 충돌은 NetworkRunner.GetPhysicsScene().OverlapCapsule
을 사용하여 감지됩니다. 해당 메소드는 지정된 매개변수로 캡슐을 투사하여, NetworkRunner
가 시뮬레이션하는 물리 장면 내의 객체가 범위 안에 있는지를 확인합니다. 반환된 값은 충돌된 객체의 수를 나타내며, 충돌된 객체가 수집 가능한 아이템일 경우 게임은 이를 수집하려 시도합니다.
로컬 대 원격 감지
아이템 수집 시 반응성을 높이기 위해, 보석이 수집되면 입자 효과와 소리가 로컬에서 즉시 재생됩니다. 플레이어가 해당 아이템에 대한 상태 권한을 가지고 있지 않다면, 충돌 후 RPC를 통해 SharedMasterModeClient
에게 RPC_Collect
를 보냅니다. 상태 권한이 있는 클라이언트가 수집을 확인하면, 다시 원래 플레이어에게 Player.RPC_Reward
RPC를 전송합니다.
이 접근 방식에 있어 다음 사항을 유의해야 합니다:
- 네트워크 지연이 낮은 플레이어(특히
SharedMasterModeClient
)가 두 명의 플레이어가 동시에 수집을 시도할 경우 더 쉽게 아이템을 획득할 수 있습니다. - 네트워크 지연이 높은 플레이어가 아이템을 상호작용할 때 더 큰 지연이 관찰될 수 있습니다. 이는
OverlapCapsule
호출이 로컬 플레이어에 의해 수행되고 다른 플레이어는 호출되지 않기 때문입니다. 아이템이Collected
로 설정되고 해당 변경 사항이 감지된 이후에만 효과가 재생됩니다.
부서질 수 있는 오브젝트(예: 나무 통과 뼈 벽)도 로컬 반응을 먼저 생성하고 나서 RPC를 통해 해당 객체의 상태 권한으로 신호를 보냅니다.
스위치
이 샘플에서는 빨간색 X로 표시된 스위치가 있습니다. 스위치 위에 올라가면 초록색으로 변하며 다리가 연결됩니다. 스위치에서 내려오면 다리가 다시 원래 상태로 돌아가기 때문에 협력이 필요합니다.
이 스위치들도 아이템과 마찬가지로 로컬 플레이어에게 반응성을 높이기 위해 네트워크 속성이나 RPC 대신 OnTriggerEnter
와 OnTriggerExit
메소드를 사용합니다. 원격 플레이어라 하더라도 스위치의 트리거에 들어오면 반응하게 됩니다. 네트워크 지연이 있더라도 원격 플레이어의 NetworkTransform
이 결국 스위치의 트리거에 들어와 이를 활성화할 것으로 가정합니다.
적
이 샘플의 주요 적은 칼을 휘두르는 상어들입니다. 이들은 고유의 FSM 시스템과 NavMesh 내비게이션을 사용하며, 상태 권한 전환 기능을 활용합니다.
주요 적 AI 흐름:
- 상어는 근처에 있는 플레이어를 찾습니다.
- 플레이어가 감지되면 해당 플레이어를 쫓기 시작합니다.
- 추적 중인 플레이어는 상어에 대한 상태 권한을 요청하여 전투 시 반응성을 높입니다.
- 상어가 플레이어에게 충분히 가까워지면 공격합니다.
- 목표로 삼은 플레이어가 사망하거나 범위에서 벗어나면, 상어는 원래 위치로 돌아가려 합니다.
상어가 목표로 삼는 위치는 네트워크를 통해 공유되므로, 상태 권한이 변경되더라도 상어는 같은 목표 위치를 계속 추적합니다.
보스
플레이어가 탐험을 마치는 섬의 끝에는 보스의 촉수가 기다리고 있습니다. SharedMasterModeClient가 이 촉수에 대한 상태 권한을 가지며, 무작위 애니메이션을 선택하고 AttackIndex
를 설정합니다. 이 속성은 NetworkProperty
이며, OnChangedRender
속성을 사용합니다. 변경이 감지되면 애니메이션이 로컬에서 재생됩니다. 이는 공격하는 촉수가 플레이어에게 더 반응적으로 보이게 합니다. 네트워크 지연이 높은 세션에서는 촉수가 공격하지 않는 것처럼 보일 수 있지만, 로컬 플레이어에게 부드러운 경험을 제공하는 것이 주요 목표입니다.
네 개의 촉수를 모두 처치하면 보스 캐릭터가 소환됩니다. 보스는 공격을 하지 않으며, 플레이어의 공격에 반응만 합니다. 보스가 패배하면 게임이 종료됩니다. 보스의 상태 권한은 BossDeathListener
컴포넌트를 가진 모든 동작에 RPC를 전송하여 적을 비활성화하고 플레이어의 움직임을 멈춥니다.