Unity 클라이언트
sync와 stateMachine 룸에서는 룸에 있는 오브젝트를 Unity의 GameObject로 표시합니다. 이 페이지에서는 그 Unity 측의 설정과 API를 하고 싶은 작업별로 설명합니다.
Photon(PUN2)에서 이전하는 경우에는 Photon에서의 이전 가이드에 대응표가 있습니다.
처음 사용하는 경우에는 먼저 튜토리얼에서 전체를 한 번 동작시켜 보면 각 설정의 역할을 파악하기 쉬워집니다. 서버 측 룰의 작성 방법은 룸 정의 레퍼런스를 참조해 주세요.
전체 구조
| 용어 | 의미 | 적이 10체 있는 경우의 예 |
|---|---|---|
| 공유 오브젝트 | 룸이 만들고, 참가자 전원이 같은 상태를 참조하는 대상 | 10체의 적 |
| 플레이어 오브젝트 | 플레이어가 생성하고 소유하는 오브젝트. 소유자가 값을 변경한다 | 각 플레이어의 캐릭터 |
| 오브젝트 ID | 룸 내의 하나의 실체를 나타내는 ID. 서버가 발행한다 | 적마다 다른 10개의 ID |
| Prefab ID | 오브젝트의 종류를 나타내는 ID. Unity에서 표시할 Prefab을 선택하는 데 사용한다 | 10체 모두 같은 ID |
| 동기화 프로퍼티 | 참가자에게 배포하는 값 | 위치, HP |
Unity에서는 다음 3가지 컴포넌트를 사용합니다. 네임스페이스는 Gs2.Realtime.Sync입니다.
| 컴포넌트 | 배치 위치 | 역할 |
|---|---|---|
NetworkWorld | 씬에 1개 | 수신한 상태로부터 GameObject를 생성·갱신·삭제하고, 변경을 모아서 전송한다 |
NetworkEntity | 동기화할 Prefab의 루트 | GameObject와 공유 오브젝트를 대응시킨다. 값의 읽기·쓰기, 표시 반영, 조작 권한을 다룬다 |
NetworkTransform | 위치·회전을 동기화할 Prefab | 조작할 수 있는 오브젝트의 이동을 전송하고, 다른 클라이언트에서는 수신한 위치로 보간한다 |
서버가 갱신하는 오브젝트에 조작을 전송하려면 SharedObject도 추가합니다.
데이터는 다음 순서로 도착합니다.
- 룸에 참가하면 그 시점의 모든 오브젝트와 값(스냅샷)이 도착하고, NetworkWorld가 GameObject를 생성합니다.
- 이후에는 변경된 값만 도착하여 표시가 갱신됩니다.
룸에 접속하기
NetworkWorld는 접속이나 로그인을 수행하지 않습니다. GS2 SDK의 RealtimeSession으로 접속하고, 다음 순서로 NetworkWorld에 전달합니다. 구체적인 코드는 튜토리얼의 접속 스크립트를 참조해 주세요.
RealtimeSession으로 접속하여MyConnectionId(자신의 접속 ID)를 얻습니다.world.Attach(connectionId, send)를 호출합니다.send는 전송 데이터를 받아 전송 큐에 쌓는 함수입니다.session.OnMessageWithMetadata로 받은 데이터를world.Receive(payload, metadata.LifeTimeMilliSeconds)에 전달합니다. 참가 직후의 스냅샷을 놓치지 않도록session.Dispatch()를 시작하기 전에 등록합니다.- 접속이 끊기면
world.Detach()를 호출합니다. 표시 중인 GameObject는 모두 삭제됩니다.
LifeTimeMilliSeconds는 룸 서버의 시계입니다. 이를 전달하면 룸의 참가자에게 공통인 시각 world.ServerTime(초)을 사용할 수 있으며, 다른 플레이어의 오브젝트가 수신 시각에 맞춰 부드럽게 움직입니다. world.Receive(payload)만으로도 동작합니다.
UDP로 상태를 전송하는 방법은 UDP로 상태 전송하기를 참조해 주세요.
send는 데이터 배열과 유효한 길이를 받아, 전송 큐에 쌓았다면 true를 반환합니다. 배열은 재사용되므로 유효한 범위를 복사한 후 쌓습니다. 전송에 실패하면 변경을 버리지 말고 접속을 끊은 후, 다시 접속하여 스냅샷으로부터 표시를 다시 만듭니다.
RelayRealtimeSession은 메시지를 다른 형식으로 감싸므로 이 용도로는 사용할 수 없습니다.
Prefab 등록하기
서버로부터 도착한 오브젝트를 표시할 Prefab을 Prefab ID별로 등록합니다.
- 표시용 Prefab을 만들고, 루트에 NetworkEntity를 추가합니다. 위치·회전도 동기화한다면 NetworkTransform도 추가합니다.
- Project 창의 Create > GS2 > Realtime > Prefab Catalog에서 카탈로그를 만듭니다.
- 카탈로그의 Prefabs에 Prefab ID(Id)와 표시용 Prefab을 등록합니다.
- 씬의 NetworkWorld > Prefab Catalog에 카탈로그를 지정합니다.
서버로부터 도착할 가능성이 있는 Prefab ID는 모두 등록해 주세요. 등록되지 않은 ID가 도착하면 오류가 발생합니다. Prefab의 루트에는 NetworkEntity가 필요합니다.
공유 오브젝트는 룸이 만듭니다. 플레이어가 자신의 오브젝트를 만들 때는 Unity의 Instantiate가 아니라 world.Instantiate를 사용합니다. Unity의 Instantiate로 만든 GameObject는 다른 참가자와 공유되지 않습니다.
룸이 생성이나 RPC를 검사하게 하려면 오브젝트 카탈로그를 설정합니다.
위치·회전 동기화하기
위치·회전은 SDK에 표준 필드가 있어, 동기화 스키마 에셋이 없어도 동기화할 수 있습니다. 서버 측에서 동기화를 허가한 축만 NetworkTransform에서 선택합니다.
| 서버의 선언 | Unity에서 동기화할 수 있는 축 | 단위 간격(표준) |
|---|---|---|
RoomSync.Position | Position X / Y / Z | 0.01(Unity의 거리 단위) |
RoomSync.Rotation | Rotation X / Y / Z | 360 / 4096도 |
RoomSync.Transform | 위의 양쪽 모두 | 위와 동일 |
NetworkTransform의 설정은 다음과 같습니다.
| 항목 | 내용 |
|---|---|
| Position Axes / Rotation Axes | 동기화할 축. 서버가 허가하지 않은 축을 선택하면 오류가 된다. 위치만 허가되어 있다면 Rotation Axes는 None |
| Target | 움직일 Transform. 비어 있으면 자기 자신 |
| Interpolation | 서버 시각을 전달하지 않을 때의 보간 속도. 0이면 수신한 값으로 즉시 이동한다 |
| Teleport Distance | 이 거리보다 떨어진 위치로는 보간하지 않고 이동한다(리스폰 등). 0이면 항상 보간한다 |
| Interpolation Delay | 서버 시각을 전달할 때, 몇 초 전의 자세를 표시할지(기본값 0.1). 전후로 수신한 갱신 사이를 보간한다 |
위치는 월드 좌표, 회전은 오일러 각으로 다룹니다. 3축 모두 회전을 동기화하는 경우에는 회전을 Quaternion으로 보간합니다. 선택하지 않은 축은 각 클라이언트의 로컬 값 그대로입니다. Scale은 동기화하지 않습니다. 갱신이 멈추면 마지막으로 수신한 자세에서 멈추며, 앞으로의 위치를 예측하지는 않습니다.
조작할 수 있는 오브젝트에서는 이동 스크립트로 평소처럼 Transform을 움직이면, NetworkTransform이 값을 단위 간격으로 반올림하여 전송합니다. 단위 간격보다 작은 흔들림은 전송되지 않습니다.
독자적인 값 표시하기
HP나 개폐 상태 등의 독자적인 값은 서버 측에서 RoomSchema로 선언하고, 생성된 동기화 스키마(.realtimeschema)를 Unity에서 사용합니다. 선언 작성 방법은 동기화 스키마를 참조해 주세요.
- 생성된
.realtimeschema를Assets아래에 배치합니다. 동기화 스키마 에셋으로 가져와집니다. - Prefab의 NetworkEntity > Schema에 그 에셋을 지정합니다.
- NetworkEntity > Property bindings > Add binding에서 항목을 추가하고, Field에서 값을 선택합니다.
- Apply to에 반영 대상을 등록하고, 수신한 값을 인수로 받는 Dynamic 이벤트를 선택합니다.
이벤트 인수의 타입은 동기화 스키마의 타입(int / bool / float)에 대응합니다. 예를 들어 bool을 GameObject의 SetActive(bool)에, float를 Slider의 value에 연결할 수 있습니다. 참가 직후와 값이 바뀔 때마다 호출됩니다.
스크립트에서는 NetworkEntity의 다음 메서드로 값을 읽고 쓸 수 있습니다.
| 메서드 | 내용 |
|---|---|
GetInt / GetBool / GetFloat(name) | 수신한 값을 읽는다 |
SetInt / SetBool / SetFloat(name, value) | 값을 전송한다. 해당 오브젝트의 쓰기 권한이 필요 |
스크립트로 송수신을 한꺼번에 작성하는 경우에는 INetworkObservable을 구현한 컴포넌트를 Prefab에 배치합니다. Photon의 OnPhotonSerializeView에 해당합니다.
public sealed class Health : MonoBehaviour, INetworkObservable
{
public float Value;
// 소유자 측에서, 모아서 전송하기 직전에 호출된다
public void OnNetworkWrite(NetworkEntity entity) => entity.SetFloat("Health", Value);
// 다른 클라이언트에서, 값이 도착했을 때 호출된다
public void OnNetworkRead(NetworkEntity entity, uint changed)
{
if (entity.HasChanged("Health", changed))
Value = entity.GetFloat("Health");
}
}Property bindings는 수신한 값을 표시에 전달할 뿐입니다. Slider를 로컬에서 움직여도 서버의 값은 바뀌지 않습니다. 서버가 관리하는 값은 서버에 조작 전송하기로 변경합니다.
조작 권한을 사용하여 값 전송하기
서버가 「조작 권한을 대여한다」고 선언한 오브젝트는 조작 권한을 얻은 플레이어만 값을 전송할 수 있습니다. 상자를 들고 옮기거나, 패들을 움직이는 등의 용도에 사용합니다.
| 멤버 | 내용 |
|---|---|
RequestControl() / ReleaseControl() | 조작 권한 취득을 요청한다 / 반환한다. Button의 On Click에도 등록할 수 있다 |
| Acquire On Enable | 활성화되었을 때 자동으로 취득을 요청한다 |
| Auto Renew | 조작 권한의 기한이 만료되기 전에 자동으로 갱신한다(기본값으로 활성) |
| Controlled Behaviours | 조작 권한이 있는 동안에만 활성화할 스크립트(이동이나 입력 처리). 네트워크용 컴포넌트는 등록하지 않는다 |
HasControl / Control Changed | 지금 조작할 수 있는지 / 조작 가능 상태가 바뀌었을 때의 이벤트 |
LastResult / Command Completed | 취득·반환 요청의 결과 |
취득 요청은 비동기로 처리되므로, 요청한 직후에 조작할 수 있다고는 할 수 없습니다. 다른 플레이어가 조작 권한을 가지고 있을 때는 LeaseBusy로 거부됩니다. 거부된 경우에는 자동으로 재요청하지 않으므로, 필요하다면 RequestControl()을 다시 호출합니다.
GameObject가 비활성화되면 조작 권한은 자동으로 반환됩니다.
서버에 조작 전송하기
서버가 관리하는 오브젝트(적의 HP 등)는 클라이언트에서 값을 변경할 수 없습니다. 대신 SharedObject.Interact(method)로 「이 오브젝트에 method의 조작을 한다」는 요청을 전송합니다. 요청은 서버 룰의 Interact에 전달되고, 룰이 검증하여 값을 갱신합니다.
var target = GetComponent<SharedObject>();
var request = target.Interact(1); // 전송할 수 없으면 0method는 룰에서 조작의 종류를 구분하기 위한 번호입니다. 추가 인수는 전송할 수 없습니다.
요청의 결과는 NetworkWorld.Client.CommandCompleted로 받습니다. 인수는 오브젝트 ID, Interact의 반환값인 요청 ID, 결과입니다.
world.Client.CommandCompleted += (objectId, request, result) =>
{
Debug.Log($"#{objectId}의 요청 {request}: {result}");
};| 결과 | 값 | 의미 |
|---|---|---|
Success | 0 | 수리되어 변경이 전원에게 배포되었다 |
MissingObject | 1 | 대상 오브젝트가 없다 |
Forbidden | 2 | 이 오브젝트에는 허가되지 않은 조작 |
LeaseBusy | 3 | 다른 플레이어가 조작 권한을 가지고 있다 |
StaleEpoch | 4 | 조작 권한 상태가 바뀐 후의 오래된 요청 |
InvalidOperation | 5 | 현재 상태에서는 받아들이지 않는 조작, 또는 룰의 실행 오류 |
AlreadyApplied | 6 | 룰이 이미 적용 완료로 반환했다 |
StaleRequest | 7 | 같은 요청 ID가 이미 처리 완료 |
| 룰의 거부 이유 | 1 이상의 임의의 값 | 룰이 Step.Ignore로 반환한 값 |
룰이 Step.Ignore로 거부한 경우에는 그 이유의 수치가 그대로 전달됩니다. 위의 결과와 구별할 수 있도록 룰의 거부 이유에는 1000 이상의 값을 사용해 주세요.
플레이어 오브젝트 만들기
플레이어가 조작하는 캐릭터 등, 플레이어가 소유하는 오브젝트는 world.Instantiate로 만듭니다. Photon의 PhotonNetwork.Instantiate에 해당합니다.
var entity = world.Instantiate(prefabId, position, rotation);
entity.Rpc(nameof(Weapon.Equip), RpcTarget.AllBuffered, 3); // 생성 직후부터 사용할 수 있다- 반환값인 NetworkEntity는 그 자리에서 자신의 소유(
IsMine)로 사용할 수 있습니다. 이동이나 RPC는 룸이 생성을 확정할 때까지 보류되며, 확정 후에 전송됩니다. 확정될 때까지 움직인 위치는 유지됩니다.IsSpawned는 확정되면true가 됩니다. - 위치와 회전은 Prefab의 NetworkTransform에서 동기화하는 축에 설정됩니다.
- 룸이 생성을 거부하면(소유 수의 상한 초과나, 오브젝트 카탈로그를 설정한 룸에서 등록되지 않은 Prefab)
world.SpawnFailed가 호출되고, 인스턴스는 삭제됩니다. - 생성 확정을 기다린 후 GameObject를 만드는 경우에는
world.Spawn(prefabId, position, rotation, entity => ...)을 사용합니다.
| 멤버 | 내용 |
|---|---|
IsMine | 자신이 변경할 수 있는지(플레이어 오브젝트의 소유자, 또는 리스의 조작 권한 보유자) |
OwnerId | 소유자의 접속 ID. 공유 오브젝트에서는 조작 권한 보유자(없으면 0) |
Destroy() | 자신의 플레이어 오브젝트를 삭제한다 |
TransferOwnership(connectionId) | 소유권을 다른 참가자에게 넘긴다(Photon의 TransferOwnership) |
RequestOwnership() | 소유권을 취득한다(Photon의 RequestOwnership). 오브젝트 카탈로그를 설정한 룸에서는 "ownership": "takeover"인 오브젝트만 |
OwnershipChanged | 소유자가 바뀌었을 때의 이벤트 |
소유자가 퇴장하면 그 플레이어 오브젝트는 삭제됩니다. 오브젝트 카탈로그에서 "keepOnLeave": true로 한 오브젝트는 마스터 클라이언트에게 인계됩니다.
RPC
Prefab 컴포넌트의 메서드에 [NetworkRpc]를 붙이면 다른 클라이언트에서 호출할 수 있습니다. Photon의 [PunRPC]와 photonView.RPC에 해당합니다.
public sealed class Weapon : MonoBehaviour
{
[NetworkRpc]
void Fire(Vector3 direction, int damage, NetworkRpcInfo info)
{
// info.Sender는 호출한 플레이어의 접속 ID(서버가 설정한다)
}
}
entity.Rpc(nameof(Weapon.Fire), RpcTarget.Others, direction, 10);
entity.RpcTo(nameof(Weapon.Fire), connectionId, direction, 10); // 1명에게만 전송한다| 대상 | 전달되는 상대 |
|---|---|
RpcTarget.All | 호출한 플레이어를 포함한 전원 |
RpcTarget.Others | 호출한 플레이어 이외 |
RpcTarget.Owner | 오브젝트의 소유자 |
RpcTarget.AllBuffered / OthersBuffered | 위에 더해, 나중에 참가한 플레이어에게도 전달된다. 오브젝트 삭제나 호출한 플레이어의 퇴장으로 파기된다 |
RpcTo(method, connectionId, ...) | 지정한 1명 |
- RPC의 ID는 메서드 이름으로부터 정해집니다(
[NetworkRpc(id)]로 고정할 수도 있다). NetworkEntity의 Inspector에 Prefab에 있는 RPC의 ID가 표시됩니다. - 누가 어떤 RPC를 호출할 수 있는지 제한하려면 오브젝트 카탈로그를 설정합니다. 설정하면
methods에 등록한 ID는 소유자만 호출할 수 있게 되며, 다른 플레이어의 오브젝트에도 호출하는 RPC는publicMethods에 등록합니다([NetworkRpc(AnyCaller = true)]를 붙이면 카탈로그를 생성할 때 이쪽에 들어간다). - 인수로 사용할 수 있는 타입은 bool, 정수형, char, float, double, string, byte[], enum, Vector2/3/4, Vector2Int/3Int, Quaternion, Color입니다. 합계 4096바이트까지입니다. 마지막 인수를
NetworkRpcInfo로 하면 호출자를 받을 수 있습니다.IEnumerator를 반환하면 코루틴으로 실행됩니다. - 수신한 RPC는 도착한 순서대로, 수신 처리를 마친 후에 호출됩니다. RPC 안에서 RPC를 전송하거나 값을 변경할 수 있습니다.
- 공유 오브젝트에는 RPC를 전송할 수 없습니다. 서버에 조작 전송하기를 사용합니다.
룸 이벤트
오브젝트에 연결되지 않은 알림은 룸 이벤트로 전송합니다. Photon의 RaiseEvent와 OnEvent에 해당합니다.
public sealed class Scoreboard : MonoBehaviour
{
void OnEnable() => world.AddEventTarget(this);
void OnDisable() => world.RemoveEventTarget(this);
[NetworkEvent]
void Goal(int team, NetworkRpcInfo info) { /* ... */ }
}
world.RaiseEvent("Goal", RpcTarget.All, 1);
world.RaiseEventTo("Goal", connectionId, 1);참가자라면 누구나 전송할 수 있습니다. 대상은 All / Others와 각각의 Buffered, 또는 1명입니다. Buffered 이벤트는 전송한 플레이어가 퇴장하면 파기됩니다. 인수의 인코딩은 등록한 핸들러 메서드의 선언을 따릅니다. 핸들러를 사용하지 않고 바이트열을 다루는 경우에는 RaiseRawEvent와 EventReceived를 사용합니다.
커스텀 프로퍼티
룸이나 플레이어에 붙이는 값입니다. 나중에 참가한 플레이어에게도 전달됩니다. Photon의 룸·플레이어 커스텀 프로퍼티에 해당합니다.
world.SetRoomProperty("stage", "forest");
world.SetPlayerProperty("team", 2); // 자신의 프로퍼티만 쓸 수 있다
world.RoomPropertyChanged += key => { /* ... */ };
if (world.TryGetPlayerProperty<int>(connectionId, "team", out var team)) { /* ... */ }- 값으로 사용할 수 있는 타입은 RPC의 인수와 같습니다(enum은 정수로 저장한다).
null을 쓰면 삭제합니다. - 쓰기는 룸을 경유하여 전원에게 도착한 시점에 반영됩니다. 쓴 플레이어의 로컬에서도 마찬가지입니다.
- 플레이어가 퇴장하면 그 플레이어의 프로퍼티는 삭제됩니다.
- 키는 64바이트까지, 룸 전체에서 1024건까지입니다.
마스터 클라이언트
룸은 참가자 중 1명을 마스터 클라이언트로 합니다. 처음 참가한 플레이어가 마스터가 되며, 마스터가 퇴장하면 남은 플레이어 중 가장 먼저 참가한 플레이어로 넘어갑니다.
| 멤버 | 내용 |
|---|---|
world.MasterClientId / IsMasterClient | 마스터의 접속 ID / 자신이 마스터인지 |
world.SetMasterClient(connectionId) | 마스터를 변경한다. 마스터만 호출할 수 있다 |
world.MasterClientChanged | 마스터가 바뀌었을 때의 이벤트 |
게임 진행을 서버에서 결정하고 싶은 경우에는 마스터 클라이언트가 아니라 stateMachine 룰을 사용하면 클라이언트 개조의 영향을 받지 않습니다.
Interest Group
오브젝트를 그룹으로 나누어, 구독하고 있는 플레이어에게만 갱신을 전달합니다. Photon의 PhotonView.Group과 SetInterestGroups에 해당합니다.
entity.SetGroup(5); // 소유자가 오브젝트의 그룹을 정한다(0은 전원에게 전달된다)
world.SubscribeGroup(5); // 그룹 5의 갱신을 받는다
world.UnsubscribeGroup(5);- 그룹으로 필터링되는 것은 값의 갱신과, 그 오브젝트에 대한 All / Others 대상 RPC입니다. 생성·삭제·소유자나 그룹의 변경은 전원에게 전달됩니다.
- 구독하면 그 그룹 오브젝트의 현재 값이 즉시 도착합니다.
- 구독은 재접속해도 유지됩니다. 1접속당 256그룹까지입니다.
틱으로 움직이는 오브젝트 조작하기
룸의 룰이 DefineTickRate와 DefineSimulatedPrefab을 사용하는 경우, 그 종류의 오브젝트는 서버가 입력으로부터 움직입니다(틱). 클라이언트는 위치를 쓰지 않고 입력을 보냅니다.
// 자신의 캐릭터를 생성한다(Simulated Prefab)
var me = world.Instantiate(5, Vector3.zero, Quaternion.identity);
// 매 프레임 현재 입력을 설정한다
void Update()
{
world.SetInput(0, Mathf.RoundToInt(Input.GetAxisRaw("Horizontal")));
world.SetInput(1, Mathf.RoundToInt(Input.GetAxisRaw("Vertical")));
world.SetInput(2, Input.GetButton("Fire1"));
// Aim direction for Room.Raycast (x 1000)
world.SetInput(3, Mathf.RoundToInt(aim.x * 1000));
world.SetInput(4, Mathf.RoundToInt(aim.y * 1000));
}- 입력은 32개 정수 슬롯의 “현재 상태"입니다.
SetInput(slot, int)·SetInput(slot, bool)·SetInput(slot, float, precision)으로 설정하며, 룸의 틱마다 전송됩니다. 틱 단위로 설정하고 싶을 때는InputSampling이벤트를 사용합니다. - 자신이 소유한 Simulated 오브젝트가 있는 동안에만 보냅니다. 1회 전송에 직전 3틱 분량의 입력을 넣으므로, UDP에서 하나가 유실되어도 보완할 수 있습니다. UDP를 사용할 수 없는 환경(WebGL 등)에서는 WebSocket으로 보냅니다(UDP).
- 동작하는 샘플이 SDK의
Assets/Scenes/Samples/Realtime/Arena에 있습니다(이동 예측, 지연 보상이 적용된 사격, 예측 스폰 로켓). NetworkWorld는 입력이 룸의 처리보다InputLeadTicks(기본 2틱) 먼저 도착하도록, 입력에 붙이는 틱 번호(InputTick)를 룸보다 조금 앞서 진행시킵니다. 룸에서 오는Clock을 보고 진행 속도를 ±10% 이내에서 자동 조정합니다.- 오브젝트는 룸의 계산 결과를 표시합니다.
IsMine은 소유자에서true가 되지만 값을 쓰거나Destroy할 수는 없습니다. 삭제는 룰의Despawn이나 소유자의 퇴장으로 이루어집니다.
자신의 캐릭터를 예측하여 움직이기
룰에 [Predict]를 붙인 처리가 있으면(예측) 컴파일러가 <룰 클래스 이름>Prediction.cs를 출력합니다. 이것을 Unity 프로젝트에 넣고 등록합니다.
TickRoomPrediction.Register(world); // 룰 클래스가 TickRoom인 경우. Attach 전후 어느 쪽이든 괜찮음- 등록하면 자신이 소유한 Simulated 오브젝트는 입력한 틱의 결과를 바로 표시합니다. 왕복 통신 시간을 기다리지 않습니다.
- 룸의 계산 결과가 도착할 때마다 그 결과에서 현재 틱까지 다시 계산합니다. 예측이 빗나갔을 경우에는
NetworkTransform이 차이를 약CorrectionTime(기본 0.1초)에 걸쳐 눈에 띄지 않게 좁힙니다. NetworkObject.Values는 룸에서 도착한 값 그대로입니다. 예측한 값은PredictedValues로 읽을 수 있습니다.IsPredicted가true인 오브젝트가 예측 대상입니다.- 다른 플레이어의 캐릭터는 지금까지처럼 룸의 결과를 보간하여 표시합니다.
- 예측 안에서 생성된 오브젝트(예측 스폰)는
NetworkWorld.PredictedSpawned로 통지됩니다. 룸의 오브젝트가 도착하면 같은 인스턴스가NetworkEntity.Object를 갖게 됩니다. 예측으로 삭제된 오브젝트는 룸이 삭제할 때까지 그려지지 않습니다(NetworkObject.PredictedRemoved). - 입력에는 그때 다른 오브젝트가 화면에서 얼마나 늦게 표시되고 있었는지가 붙습니다. 룸은 이것을 사용하여 쏜 쪽의 화면대로 판정합니다(지연 보상).
NetworkWorld.ViewDelay는NetworkTransform.InterpolationDelay와 같은 값(기본 0.1초)으로 하세요.ViewDelay는 Inspector에서도 설정할 수 있습니다. 정확하게 계산하려면world.Receive(bytes, milliseconds)로 서버 시각을 전달해야 합니다(룸에 접속하기).
UDP로 상태 전송하기
위치 등의 정기적인 갱신을 UDP로 전송하면 통신이 막혔을 때의 지연을 줄일 수 있습니다. Photon의 unreliable 전송에 해당합니다. 생성·RPC·이벤트·프로퍼티 등 도착하지 않으면 곤란한 것은 WebSocket 그대로입니다.
var udp = new RealtimeUdpChannel(session); // 접속 후에 만든다
// 매 프레임
udp.Poll((bytes, milliseconds) => world.Receive(bytes, milliseconds));
world.Unreliable = udp.Active ? udp.TrySend : (TrySend)null;Poll이 룸으로의 등록과 등록 유지를 수행합니다. 룸으로부터 응답이 있으면Active가true가 되고, 10초간 응답이 없으면false로 돌아갑니다.false인 동안에는 WebSocket으로 전송합니다.- UDP로 전송한 값은 누락에 대비하여 1초마다 재전송됩니다. 따라서 값이 손실되더라도 최대 약 1초 후에는 올바른 값이 됩니다.
- RPC 등의 직전에 전송하는 값은 WebSocket으로 전송하므로 RPC보다 먼저 도착합니다.
- WebGL에서는 UDP를 사용할 수 없습니다. WebSocket만으로 동작합니다.
오브젝트 카탈로그
룸이 플레이어 오브젝트의 종류, 필드, RPC, 소유권 요청을 검사하게 하려면 Realtime 네임스페이스 설정의 syncSetting.objectCatalog에 검사 기준을 JSON으로 설정합니다. 키는 Prefab ID입니다.
설정하지 않으면 룸은 이것들을 검사하지 않습니다. 등록되지 않은 Prefab도 생성 시 선언한 필드를 가진 플레이어 오브젝트로 생성할 수 있고, 어떤 RPC든 누구나 호출할 수 있으며, 누구나 소유권을 취득할 수 있습니다. 설정하면 등록한 Prefab, 필드, RPC만 통과합니다.
{
"1": {
"fields": 63,
"mode": "player",
"methods": [1],
"publicMethods": [2],
"ownership": "takeover",
"keepOnLeave": true
}
}| 항목 | 내용 |
|---|---|
fields | 동기화할 필드의 비트 마스크. 위치 X/Y/Z가 1/2/4, 회전 X/Y/Z가 8/16/32 |
mode | 플레이어 오브젝트는 "player" |
methods | 소유자가 호출할 수 있는 RPC의 ID(위의 예는 [NetworkRpc(1)]처럼 ID를 고정한 경우. 이름으로부터 정해지는 ID는 Inspector나 Copy Object Catalog JSON으로 확인한다) |
publicMethods | 참가자 누구나 다른 플레이어의 오브젝트에 대해 호출할 수 있는 RPC의 ID |
ownership | "fixed"(기본값) 또는 "takeover"(누구나 소유권을 취득할 수 있다) |
keepOnLeave | true라면 소유자 퇴장 시 삭제하지 않고 마스터 클라이언트에게 인계한다. 카탈로그에 등록한 Prefab에서만 사용할 수 있다 |
직접 작성하는 대신 카탈로그 에셋의 우클릭 메뉴 Copy Object Catalog JSON으로 생성할 수 있습니다. stateMachine에서 공유 오브젝트를 사용하는 경우에는 룰이 정의하는 Prefab과 함께 등록합니다.
오브젝트의 수명과 재접속
| 대상 | 접속이 끊겼을 때 | 재접속했을 때 |
|---|---|---|
| 서버가 만든 공유 오브젝트 | 룸에 남는다 | 스냅샷으로 그 시점의 값 그대로 다시 표시된다 |
| 자신의 플레이어 오브젝트 | 삭제된다(keepOnLeave라면 마스터에게 인계된다) | 필요하면 다시 만든다 |
| 자신의 플레이어 프로퍼티·Buffered RPC와 이벤트 | 삭제된다 | 필요하면 다시 설정한다 |
| 룸의 프로퍼티 | 룸에 남는다 | 스냅샷으로 도착한다 |
| 가지고 있던 조작 권한 | 반환된다 | 필요하면 다시 취득한다 |
| 자신의 접속 ID | 무효가 된다 | 새로운 ID가 발행된다 |
재접속할 때마다 도착한 스냅샷으로부터 표시를 다시 만듭니다. 클라이언트에서 룰이나 조작 이력을 다시 실행할 필요는 없습니다.
같은 userId로 다시 접속하면 이전 접속은 퇴장한 것으로 취급됩니다.
룸 종료 조건과의 관계
룸은 참가자 전원으로부터 1분간 통신이 없으면 종료됩니다. sync와 stateMachine 룸에서는 접속을 유지하기 위한 Ping은 이 통신에 포함되지 않습니다.
NetworkWorld는 값이 바뀌었을 때만 전송합니다. UDP 등록 유지도 이 통신에 포함되지 않습니다. 따라서 전원이 조작하지 않고 기다리는 장면이 있는 게임에서는 그동안에도 룸이 유지되도록 정기적으로 조작을 전송하는 등의 설계가 필요합니다.
전송량 기준
동기화 데이터는 필드 이름을 전송하지 않고, 변경된 필드의 번호와 정수값만 전송합니다. NetworkWorld는 Send Rate(기본값 20회/초, world.SendRate로도 변경 가능)의 간격으로 변경을 모아서 전송합니다. 같은 값을 여러 번 쓰거나 이전에 전송한 값으로 되돌아가도 전송량은 늘어나지 않습니다.
위치의 단위 간격과 동기화할 축을 필요한 범위로 좁히면 작은 흔들림으로 인한 전송을 줄일 수 있습니다.
잘 동작하지 않을 때
| 증상 | 확인할 사항 |
|---|---|
| 오브젝트가 표시되지 않는다 | 접속 후에 스냅샷을 수신하고 있는지. 도착하는 Prefab ID가 카탈로그에 등록되어 있는지 |
Unknown prefab ID 오류가 발생한다 | 서버 룰의 Prefab ID와 카탈로그의 Id가 일치하는지 |
Select a field from the schema라고 표시된다 | 독자적인 값을 사용하는 Prefab의 Schema에 생성한 동기화 스키마를 지정했는지 |
| 위치만인데 필드 오류가 발생한다 | NetworkTransform의 Rotation Axes가 None으로 되어 있는지 |
| 움직여도 전송되지 않는다 | 조작 권한을 취득했는지. 이동 스크립트를 Controlled Behaviours에 등록했는지 |
| 값의 크기나 위치가 어긋난다 | 룰(GSL)과 동기화 스키마를 같은 C# 정의로부터 동시에 생성한 것으로 맞추었는지 |
| Inspector의 이벤트로 값이 바뀌지 않는다 | 고정값을 전달하는 이벤트가 아니라 Dynamic 이벤트를 선택했는지 |
| 공격 등의 조작이 반영되지 않는다 | CommandCompleted의 결과를 확인한다. 결과 목록을 참조 |
| RPC가 도착하지 않는다 | 오브젝트 카탈로그를 설정한 경우, RPC의 ID가 카탈로그의 methods(다른 플레이어로부터 호출한다면 publicMethods)에 있는지. 메서드 이름을 바꾸면 ID도 바뀐다 |
world.Instantiate한 오브젝트가 사라진다 | SpawnFailed의 결과를 확인한다. 소유 수의 상한(64)을 초과하지 않았는지. 오브젝트 카탈로그를 설정한 경우, Prefab이 "mode": "player"로 등록되어 있는지 |
UDP가 Active가 되지 않는다 | 룸의 포트가 UDP로 열려 있는지. WebGL에서는 UDP를 사용할 수 없다 |
룰(GSL)과 동기화 스키마는 같은 C# 정의로부터 동시에 생성한 것을 조합하여 배포해 주세요.