# Fish Game

**URL:** https://heroiclabs.com/docs/kr/nakama/tutorials/unity/fishgame/
**Summary:** Nakama와 Unity를 사용하여 Fish Game(Duck Game에 영감을 받은 빠른 속도의 2D 경쟁 슈팅 게임)을 생성하기 위한 엔드투엔드 튜토리얼.

---


# Unity Fish Game 튜토리얼

{{< youtube "c-5nmkXXWQ8" >}}

Fish Game은 Unity 게임 엔진을 사용하여 개발한 오픈 소스 게임 프로젝트입니다. [Duck Game](https://store.steampowered.com/app/312530/Duck_Game/)에 영감을 받은 빠른 속도의 2D 경쟁 슈팅 게임으로, 최대 4명의 플레이어가 참여할 수 있습니다.

이 게임과 튜토리얼은 Nakama의 강력한 [인증](../../../concepts/authentication/), [매치메이킹](../../../concepts/multiplayer/matchmaker/), [실시간 멀티플레이어](../../../concepts/multiplayer/relayed/) 기능을 강조합니다.

{{< note "important" >}}
추가 컨텐츠에 대해서는 튜토리얼에서 [블로그 포스트](https://heroiclabs.com/blog/tutorials/unity-fishgame/) 및 [비디오 시리즈](https://www.youtube.com/playlist?list=PLOAExZcDNj9tut2gSUlw46OKK4iB--uW1)를 확인하시기 바랍니다.
{{< / note >}}

## 필수 조건

튜토리얼을 쉽게 따라하기 위해서는 진행하기 전에 다음을 실시합니다:

- [Nakama 설치](../../../getting-started/install/docker/)
- Unity 2019.4 [다운로드](https://unity3d.com/get-unity/download) 및 설치
- [Nakama Unity SDK 설치](https://github.com/heroiclabs/nakama-unity)
- [Fish Game Unity 프로젝트 다운로드](https://github.com/heroiclabs/fishgame-unity)
- [프로젝트](#project-hierarchy) 및 [자산](#asset-hierarchy) 계층 숙지

### 프로젝트 계층

Unity에서 Fish Game 프로젝트를 열고 프로젝트 계층을 확인합니다:

![Fish Game 프로젝트 계층]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/project-hierarchy.png" >}})

아래의 게임 개체가 목록으로 나열됩니다:

- **GameManager**: 매치메이킹 처리를 포함하여 모든 대결 관련 게임 로직을 제어.
- **AudioManager**: 게임 오디오를 처리.
- **UI**
    - **AudioMenu**: 오디오 음소거를 위한 간단한 토글.
    - **MainMenu**: 플레이어가 대결을 찾고, 매치메이킹 요청을 취소하거나 크레딧을 볼 수 있는 메뉴.
    - **InGameMenu**: 게임 도중에 플레이어가 ESC를 눌러서 대결을 종료하고 호출할 수 있는 메뉴.
    - **Canvas**: 승리하고 있는 플레이어 알림 레이블을 포함하는 UI 캔버스.
- **DefaultMap**: 달성 포인트 및 타일맵을 포함하는 게임 수준.
- **Main Camera**: 게임 2D 카메라 및 배경 스프라이트.
- **EventSystem**: UI 상호 작용을 처리하는 기본적인 Unity 이벤트 시스템.

### 자산 계층

![Fish Game 자산 계층]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/asset-hierarchy.png" >}})

Nakama 기능과 연관되는 폴더는 다음과 같습니다:

- **Entities** > **Player**: 총 3개의 플레이어 프리팹과 관련된 모든 `MonoBehaviour` 구성 요소를 포함.
- **Managers**: 글로벌 게임 요소(예: `GameManager`) 및 다양한 메뉴 스크립트 처리를 담당하는 구성 요소를 포함.
- **Nakama**: `ScriptableObject`인 `NakamaConnection` 클래스 및 `LocalNakamaConnection` 자산을 포함.
- **Weapons**: 무기와 발사체 처리를 담당하는 구성 요소를 포함.

## Nakama 연결

위의 자산 계층에서 보다시피, Fish Game 프로젝트에는 이미 `NakamaConnection` 클래스와 `LocalNakamaConnection` 자산이 포함되어 있습니다.
`NakamaConnection`은(는) Unity의 `ScriptableObject` 클래스에서 승계하여 프로젝트에서 `NakamaConnection` 클래스 인스턴스를 생성할 수 있고, 게임에서 다른 스크립트의 종속성으로 전달되는 자산으로 저장할 수 있습니다.

Nakama 서버로 연결하는 방법을 알아보려면 `NakamaConnection.cs` 파일을 열어보십시오. `NakamaConnection` 클래스를 선언한 후, 해당 연결 개체를 연결해야 하는 Nakama 서버를 지정할 수 있도록 일부 공용 변수를 정의하였습니다:

**NakamaConnection.cs**

```csharp
public string Scheme = "http";
public string Host = "localhost";
public int Port = 7350;
public string ServerKey = "defaultkey";
```

다음으로, `Connect` 함수의 작업을 살펴보겠습니다. 먼저, `Nakama.Client` 개체의 인스턴스를 생성하고 연결 값으로 전달합니다:

**NakamaConnection.cs**

```csharp
Client = new Nakama.Client(Scheme, Host, Port, ServerKey, UnityWebRequestAdapter.Instance);
```

이 `Client` 개체를 사용하여 Nakama와 상호 작용합니다.

다음으로, 기존 사용자 세션의 복구를 시도합니다. 예를 들어, 사용자가 이전에 게임을 시작하고 Nakama 서버에 연결된 경우, 동일한 세션이 이미 만료되지 않았으면 그 세션을 복구하고 싶습니다:

**NakamaConnection.cs**

```csharp
var authToken = PlayerPrefs.GetString(SessionPrefName);
if (!string.IsNullOrEmpty(authToken))
{
    var session = Nakama.Session.Restore(authToken);
    if (!session.IsExpired)
    {
        Session = session;
    }
}
```

여기  에서 `PlayerPrefs`로부터 인증 토큰을 받기 위해서 시도합니다. 인증 토큰을 찾은 경우, `Nakama.Session.Restore` 함수를 호출하고 인증 토큰으로 전달하여 `Session` 개체를 검색합니다. 이미 세션이 만료되지 않았는지 확인 작업을 실시합니다. 세션이 만료되지 않은 경우, 이 세션 개체를 개인적인 `Session` 변수로 지정합니다. 그렇지 않은 경우, 새로운 세션을 생성하기 위해서 재인증해야 합니다.

이러한 작업은 `Session` 변수가 null인지 확인하는 `if` 구문에서 이루어집니다:

**NakamaConnection.cs**

```csharp
// If we weren't able to restore an existing session, authenticate to create a new user session.
if (Session == null)
{
    string deviceId;

// ...
```

이것은 두 가지 잠재적인 경로를 표시합니다. 사용자가 이전에 게임을 플레이하여 장치의 고유 식별자를 저장하였거나 사용자가 이번에 처음으로 게임을 플레이하여 Nakama 서버에서 장치를 인증하기 위해서 고유한 식별자를 얻어야 한다는 의미입니다.

이전 플레이어의 경우, `PlayerPrefs`에서 `DeviceIdentifierPrefName` 키에 해당하는 값이 이미 존재하는지 확인합니다. 해당 값이 있는 경우, 나중에 함수에서 사용할 수 있도록 `deviceId` 변수에 저장합니다:

**NakamaConnection.cs**

```csharp
// If we've already stored a device identifier in PlayerPrefs then use that.
if (PlayerPrefs.HasKey(DeviceIdentifierPrefName))
{
    deviceId = PlayerPrefs.GetString(DeviceIdentifierPrefName);
}
```

새 플레이어의 경우, `SystemInfo.deviceUniqueIdentifier`에서 장치의 고유 식별자를 얻으려고 합니다. 적절한 식별자 값을 반환하지 않을 경우, `SystemInfo.unsupportedIdentifier`의 값을 반환합니다.

반환된 값을 확인하고, 해당 값이 반환된 경우 나중에 사용할 수 있게 `PlayerPrefs`에 저장하기 전에 가이드를 사용하여 고유한 식별자를 생성합니다:

**NakamaConnection.cs**

```csharp
if (deviceId == SystemInfo.unsupportedIdentifier)
{
    deviceId = System.Guid.NewGuid().ToString();
}

// Store the device identifier to ensure we use the same one each time from now on.
PlayerPrefs.SetString(DeviceIdentifierPrefName, deviceId);
```

이제 Nakama 서버로 인증을 진행하고 인증 토큰을 `PlayerPrefs`(으)로 저장할 수 있습니다:

**NakamaConnection.cs**

```csharp
// Use Nakama Device authentication to create a new session using the device identifier.
Session = await Client.AuthenticateDeviceAsync(deviceId);

// Store the auth token that comes back so that we can restore the session later if necessary.
PlayerPrefs.SetString(SessionPrefName, Session.AuthToken);
```

Nakama 세션이 유효한 상태이기 때문에 `Socket`을(를) 엽니다:

**NakamaConnection.cs**

```csharp
// Open a new Socket for real-time communication.
Socket = Client.NewSocket(true);
await Socket.ConnectAsync(Session, true);
```

이로써 Nakama 서버와 게임에 연결된 다른 플레이어와 의사소통을 시작할 수 있습니다. Fish Game에서 이 연결에 대한 호출은 `GameManager` 개체를 통해 실행됩니다:

![Fish Game GameManager 개체]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/game-manager.png" >}})

### 연결 생성

`CreateAssetMenu` 속성을 사용하고 연결에 대한 공용 변수를 정의하여 Unity Inspector에서 각각의 `NakamaConnection` 자산을 수정할 수 있습니다.

이렇게 하면 로컬, 개발, 프로덕션 서버에서 필요 시 여러 Nakama 서버에 쉽게 연결할 수 있습니다.

새로운 Nakama 연결 자산을 생성하려면 다음과 같이 합니다:

1. **자산** 메뉴에서 **생성** > **Nakama 연결**을 선택합니다.
2. **Inspector** 창에서 연결 속성을 편집합니다:
    - 구성표: `http` 또는 `https`의 연결 구성표
    - 호스트: 호스트 이름 또는 서버 IP
    - 포트: 기본적으로 `7350`(으)로 사용되는 포트
    - 서버 키: 기본적으로 `defaultkey`(으)로 서버에 연결하는 비밀 키

    ![로컬 Nakama 연결]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/nakama-connection.png" >}})

{{< note "warning" >}}
프로덕션 자산의 경우, `NakamaConnection` 자산을 `.gitignore` 파일에 추가하여 소스 제어를 커밋하거나 프로덕션 서버 비밀 키를 노출하지 않도록 합니다. 예:
    <!-- 코드 예제 -->
{{< / note >}}

## 매치메이킹

Fish Game에서 매치메이킹이 어떻게 이루어지는지 검토하기 위해서 `GameManager`, `NakamaConnection`, `MainMenu` 클래스와 관련되는 함수를 살펴보겠습니다. Nakama에서 매치메이킹에 대한 세부적인 내용은 [매치메이커 문서](../../../concepts/multiplayer/matchmaker/)를 참조하십시오.

여기서는 새로운 `Dictionary`을(를) 생성하여 연결된 플레이어를 저장하고 다음의 이벤트를 구독합니다:

- **ReceivedMatchmakerMatched**: Nakama가 대결을 찾은 경우 반응할 수 있음.
- **ReceivedMatchPresence**: 플레이어가 대결에 참여하거나 떠난 경우 반응할 수 있음.
- **ReceivedMatchState**: Nakama 서버에서 메시지를 수신할 경우 반응할 수 있음.

**GameManager.cs**

```csharp
/// <summary>
/// Called by Unity when the GameObject starts.
/// </summary>
private async void Start()
{
// Create an empty dictionary to hold references to the currently connected players.
players = new Dictionary<string, GameObject>();

// ...

// Setup network event handlers.
NakamaConnection.Socket.ReceivedMatchmakerMatched += OnReceivedMatchmakerMatched;
NakamaConnection.Socket.ReceivedMatchPresence += OnReceivedMatchPresence;
NakamaConnection.Socket.ReceivedMatchState += async m => await OnReceivedMatchState(m);

// ...
}
```

`MainMenu` 클래스에서 `FindMatch` 함수를 통해 매치메이킹 요청 사항이 어떻게 트리거되는지 살펴볼 수 있습니다.
여기서는 `NakamaConnection` 클래스에서 `GameManager`의 참조를 통해 `FindMatch` 함수를 호출하고 대결을 성사시킬 플레이어 숫자(드롭다운으로 선택)를 전달합니다.

**MainMenu.cs**

```csharp
/// <summary>
/// Begins the matchmaking process.
/// </summary>
public async void FindMatch()
{
// ...

await gameManager.NakamaConnection.FindMatch(int.Parse(PlayersDropdown.options[PlayersDropdown.value].text));
}
```

`NakamaConnection` 클래스의 `FindMatch` 함수에서 `Dictionary`은(는) Unity 엔진을 사용하고 있음을 명시하는 한 개의 `key,value` 쌍과 이 필터와 다른 지정된 매개 변수를 통해 해당 요청 사항을 매치메이킹 풀에 추가할 수 있는 함수로 선언됩니다:

**NakamaConnection.cs**

```csharp
/// <summary>
/// Starts looking for a match with a given number of minimum players.
/// </summary>
public async Task FindMatch(int minPlayers = 2)
{
// Set some matchmaking properties to ensure we only look for games that are using the Unity client.
// This is not a required when using the Unity Nakama SDK,
// however in this instance we are using it to differentiate different matchmaking requests across multiple platforms using the same Nakama server.
var matchmakingProperties = new Dictionary<string, string>
{
    { "engine", "unity" }
};

// Add this client to the matchmaking pool and get a ticket.
var matchmakerTicket = await Socket.AddMatchmakerAsync("+properties.engine:unity", minPlayers, minPlayers, matchmakingProperties);
currentMatchmakingTicket = matchmakerTicket.Ticket;
}
```

Nakama는 `MatchmakingTicket`을(를) 제공하여 반응합니다. 이 티켓으로 Nakama가 발견한 대결에 참여하거나 기존의 매치메이킹 요청을 취소할 수 있습니다:

**NakamaConnection.cs**

```csharp
/// <summary>
/// Cancels the current matchmaking request.
/// </summary>
public async Task CancelMatchmaking()
{
    await Socket.RemoveMatchmakerAsync(currentMatchmakingTicket);
}
```

매치메이킹 풀에 추가되면 Nakama가 대결을 검색하고, 대결이 검색된 경우 `ReceivedMatchmakerMatched` 이벤트 리스너를 통해 알림이 제공됩니다:

**GameManager.cs**

```csharp
/// <summary>
/// Called when a MatchmakerMatched event is received from the Nakama server.
/// </summary>
/// <param name="matched">The MatchmakerMatched data.</param>
private async void OnReceivedMatchmakerMatched(IMatchmakerMatched matched)
{
    // Cache a reference to the local user.
    localUser = matched.Self.Presence;

    // Join the match.
    var match = await NakamaConnection.Socket.JoinMatchAsync(matched);

    // ...

    // Spawn a player instance for each connected user.
    foreach (var user in match.Presences)
    {
        SpawnPlayer(match.Id, user);
    }

    // Cache a reference to the current match.
    currentMatch = match;
}
```

`Self.Presence` 값에 대해서 저장된 참조를 통해 로컬 플레이어의 Nakama 현재 상태에 빠르고 쉽게 액세스하여 세션 ID, 사용자 ID, 사용자 이름 등을 검색하는 데 유용합니다.

`JoinMatchAsync()` 반환 값에 대해서 저장된 참조를 통해 현재 대결 ID와 연결된 사용자에 액세스할 수 있습니다.

다음으로, `match.Presences` 속성과 [플레이어 생성](#spawning-players)을 통해 모든 연결된 사용자를 반복합니다.

마지막으로, 매치메이킹 시 사용자가 연결되고 해제될 때 어떤 작업을 수행할지 고려해야 합니다. 이 작업은 `OnReceivedMatchPresence` 이벤트 핸들러를 통해 처리됩니다:

**GameManager.cs**

```csharp
/// <summary>
/// Called when a player/s joins or leaves the match.
/// </summary>
/// <param name="matchPresenceEvent">The MatchPresenceEvent data.</param>
private void OnReceivedMatchPresence(IMatchPresenceEvent matchPresenceEvent)
{
    // For each new user that joins, spawn a player for them.
    foreach (var user in matchPresenceEvent.Joins)
    {
        SpawnPlayer(matchPresenceEvent.MatchId, user);
    }

    // For each player that leaves, despawn their player.
    foreach (var user in matchPresenceEvent.Leaves)
    {
        if (players.ContainsKey(user.SessionId))
        {
            Destroy(players[user.SessionId]);
            players.Remove(user.SessionId);
        }
    }
}
```

여기서, `IMatchPresenceEvent`은(는) 대결에 참여한 사용자의 목록과 대결에서 떠난 사용자의 목록을 제공합니다.

대결에 참여한 사용자의 경우, `OnReceivedMatchmakerMatched` 이벤트 핸들러와 마찬가지로 해당 사용자에 대한 플레이어 개체를 생성합니다.

대결을 떠난 사용자의 경우, 사용자의 세션 ID를 기반으로 `players` 사전에서 해당 사용자에 대한 참조가 있는지 확인합니다. 해당 사용자에 대한 참조가 있는 경우, 해당 플레이어의 `GameObject`을(를) 파괴하고 플레이어의 `Dictionary`에서 삭제합니다.

## 플레이어 생성

여기서는, 매치메이킹 또는 사용자의 현재 상태 이벤트에 대응하여 `GameManager`에 의해 호출된 `SpawnPlayer` 함수를 검토합니다.

먼저, 사용자의 세션 ID를 기반으로 플레이어 사전에 해당 사용자에 대한 참조가 있는지 확인합니다. 사용자에 대한 참조가 있는 경우, 해당 플레이어를 이미 생성했기 때문에 결과를 일찍 반환합니다:

**GameManager.cs**

```csharp
// If the player has already been spawned, return early.
if (players.ContainsKey(user.SessionId))
{
    return;
}
```

다음으로, 해당 플레이어를 생성할 장소를 결정합니다. `spawnIndex`이(가) 전달되지 않은 경우(`spawnIndex == -1`), 해당 플레이어에 대한 생성 포인트를 임의로 선택합니다. 생성 인덱스가 전달된 경우, 이를 대신 사용합니다:

**GameManager.cs**

```csharp
// If the spawnIndex is -1 then pick a spawn point at random, otherwise spawn the player at the specified spawn point.
var spawnPoint = spawnIndex == -1 ?
    SpawnPoints.transform.GetChild(Random.Range(0, SpawnPoints.transform.childCount - 1)) :
    SpawnPoints.transform.GetChild(spawnIndex);
```

다음으로, 해당 플레이어가 로컬 플레이어 또는 원격 플레이어인지 확인합니다. 이것을 기반으로 어떤 플레이어 프리팹을 생성할지 결정하고, 관련되는 생성 포인트에서 게임 내의 프리팹 인스턴스를 인스턴스화합니다:

**GameManager.cs**

```csharp
// Set a variable to check if the player is the local player or not based on session ID.
var isLocal = user.SessionId == localUser.SessionId;

// Choose the appropriate player prefab based on if it's the local player or not.
var playerPrefab = isLocal ? NetworkLocalPlayerPrefab : NetworkRemotePlayerPrefab;

// Spawn the new player.
var player = Instantiate(playerPrefab, spawnPoint.transform.position, Quaternion.identity);
```

다음으로, 해당 플레이어가 원격으로 연결된 플레이어인지 확인합니다. 원격 플레이어에 해당하는 경우, `PlayerNetworkRemoteSync` 구성 요소에 대한 참조를 찾고 적절한 네트워크 데이터를 설정합니다. 현재 대결 ID와 참조를 해당 사용자의 `IUserPresence` 개체로 전달합니다:

**GameManager.cs**

```csharp
// Setup the appropriate network data values if this is a remote player.
if (!isLocal)
{
    player.GetComponent<PlayerNetworkRemoteSync>().NetworkData = new RemotePlayerNetworkData
    {
        MatchId = matchId,
        User = user
    };
}
```

해당 데이터가 특정 플레이어를 위해서 사용된 것인지 확인하기 위해서 데이터를 수신할 때 사용됩니다.

플레이어의 세션 ID를 사용하여 플레이어를 `players` 사전에 키로 추가하고 플레이어 `GameObject`을(를) 값으로 전달합니다.

**GameManager.cs**

```csharp
// Add the player to the players array.
players.Add(user.SessionId, player);
```

다음으로, 해당 플레이어가 로컬 플레이어인지 확인합니다. 로컬 플레이어에 해당하는 경우, `PlayerDied` 이벤트에서 이벤트 리스너를 설정합니다:

**GameManager.cs**

```csharp
// If this is our local player, add a listener for the PlayerDied event.
if (isLocal)
{
    localPlayer = player;
    player.GetComponent<PlayerHealthController>().PlayerDied.AddListener(OnLocalPlayerDied);
}
```

## Player prefabs

**Entities** > **Player** 폴더에서 찾은 것처럼, Fish Game에는 `NetworkLocalPlayer`와(과) `NetworkRemotePlayer`의 프리팹이 있습니다.

### NetworkLocalPlayer

![Fish Game NetworkLocalPlayer 개체]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/network-localplayer.png" >}})

이것은 네트워크 위치 기반 플레이어 프리팹에 대한 간단한 래퍼로, 이동, 물리학, 무기, 색상을 처리하는 구성 요소가 포함되어 있습니다. 이 프리팹의 루트 `GameObject`에는 몇 가지 중요한 구성 요소가 있습니다:

- **PlayerNetworkLocalSync**: 네트워크에서 상태 업데이트 발송을 특정한 간격으로 처리.
- **PlayerInputController**: 플레이어 입력을 처리하여 기본 이동 제어기로 전달.
- **PlayerHealthController** 플레이어 건강 상태를 처리하고 `PlayerDied` 이벤트를 트리거.
- **PlayerCameraController** 카메라가 해당 플레이어를 따라갈 수 있도록 하는 간단한 구성 요소

### NetworkRemotePlayer

![Fish Game NetworkRemotePlayer 개체]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/fishgame/network-remoteplayer.png" >}})

플레이어 프리팹에 대한 래퍼로, 루트에 한 개의  구성 요소만 포함됨: `PlayerNetworkRemoteSync`.

해당 구성 요소는 네트워크로부터 상태 업데이트를 수신하고, 현재 상태가 새롭게 수신된 정확한 상태로 원활하게 삽입되도록 처리합니다. 예를 들어, 해당 개체가 `{ 100, 100 }` 위치에 있는 상태에서 네트워크 메시지가 정확한 위치는 `{ 120, 100 }`이어야 한다고 알려줄 경우, 해당 구성 요소는 `LerpTime` 초의 시간 내에 이 개체의 위치가 원활하게 수정될 수 있도록 처리합니다.

## 플레이어 위치 전송

`NetworkLocalPlayer`에서 네트워크를 통해 위치 및 입력 업데이트를 보내는 방법을 검토하기 위해서 `Entities` > `Player` > `PlayerNetworkLocalSync.cs`을(를) 살펴봅니다.

먼저, 공용 변수를 통해 네트워크 상태 동기화 업데이트가 전송되는 주기를 결정합니다:


**PlayerNetworkLocalSync.cs**

```csharp
/// <summary>
/// How often to send the player's velocity and position across the network, in seconds.
/// </summary>
public float StateFrequency = 0.1f;
```

해당 값을 변경하면 네트워크에서 상태 업데이트가 전송되는 주기가 증가하거나 감소합니다. 원활한 느낌의 멀티플레이어 게임을 생성하기 위해서는 균형점을 찾는 것이 중요합니다.

여기서 기본값은 `0.1f`이거나 1초당 10회의 틱이 발생합니다. Fish Game 예시의 경우, 이것을 `0.05f`(으)로 설정하면 1초당 20회의 틱이 발생합니다:

```csharp
public float StateFrequency = 0.1f;
```

속도가 더 빨라지면 업데이트 전송 주기가 늘어나서 다른 플레이어가 원활한 느낌을 받게 됩니다. 업데이트 전송 주기가 줄어들면 플레이어의 위치가 다른 연결된 클라이언트와 동기화되지 않으며, 보간 및 수정 과정에서 지연이 발생합니다.

다음으로, 플레이어의 위치가 업데이트된 후에 업데이트를 전송하기 위해서 사용하는 `LateUpdate` 함수를 살펴보겠습니다. 먼저, 상태 동기화 타이머가 경과되었는지 확인합니다. 시간이 경과된 경우, 새로운 업데이트를 전송해야 합니다:

**PlayerNetworkLocalSync.cs**

```csharp
// Send a network packet containing the player's velocity and position.
gameManager.SendMatchState(
    OpCodes.VelocityAndPosition,
    MatchDataJson.VelocityAndPosition(playerRigidbody.velocity, playerTransform.position));
```

다음으로, 마지막에 함수를 실행한 후에 로컬 플레이어의 입력이 변경되었는지 확인합니다. 변경되지 않은 경우, 결과를 일찍 반환합니다:

**PlayerNetworkLocalSync.cs**

```csharp
// If the players input hasn't changed, return early.
if (!playerInputController.InputChanged)
{
    return;
}
```

마지막으로, 플레이어의 현재 입력을 네트워크로 전송합니다:

**PlayerNetworkLocalSync.cs**

```csharp
// Send network packet with the player's current input.
gameManager.SendMatchState(
    OpCodes.Input,
    MatchDataJson.Input(playerInputController.HorizontalInput, playerInputController.Jump, playerInputController.JumpHeld, playerInputController.Attack)
);
```

### 작업 코드

위의 함수에서 사용된 작업 코드(`OpCodes`)는 게임에서 발생할 수 있는 특정 네트워크 활동에 대한 숫자 표현입니다.

Fish Game에 있는 모든 `OpCodes`을(를) `Entities` > `Player`> `OpCodes.cs`에서 확인할 수 있습니다. 여기에는 다음이 포함됩니다:

- `VelocityAndPosition`: 상기 내용 참조 플레이어의 속도와 위치를 포함하는 네트워크 메시지를 전송합니다.
- `Input`: 상기 내용 참조 마지막 업데이트 이후 플레이어의 입력이 변경되었음을 표시하고 현재 입력을 메시지로 전송합니다.
- `Died`: GameObject가 파괴되고 `players` 사전에서 제거된 경우 플레이어의 죽음을 표시합니다.
- `Respawned`: 네트워크를 통해 플레이어가 생성된 위치를 다른 플레이어에게 알려주도록 메시지를 전송합니다.
- `NewRound`: 네트워크를 통해 플레이어가 승리했다는 것을 다른 플레이어에게 알려주고 새 라운드를 시작하도록 메시지를 전송합니다.

## 플레이어 위치 수신

`NetworkRemotePlayer`에서 네트워크를 통해 위치와 입력 업데이트를 수신하는 방법을 검토하기 위해서 `Entities` > `Player` > `PlayerNetworkRemoteSync.cs`을(를) 살펴봅니다.

먼저, 두 개의 공용 변수가 있습니다. 한 개의 변수는 현재 대결 ID와 플레이어의 `IUserPresence` 개체에 참조를 저장하며, 다른 변수는 플레이어의 위치에서 보간 속도를 결정합니다.

**PlayerNetworkRemoteSync.cs**

```csharp
public RemotePlayerNetworkData NetworkData;

/// <summary>
/// The speed (in seconds) in which to smoothly interpolate to the player's actual position when receiving corrected data.
/// </summary>
public float LerpTime = 0.05f;
```

`Start` 함수 내에서 네트워크로부터 데이터를 수신할 때마다 리스너를 설정합니다:

**PlayerNetworkRemoteSync.cs**

```csharp
// Add an event listener to handle incoming match state data.
gameManager.NakamaConnection.Socket.ReceivedMatchState += OnReceivedMatchState;

// ...

/// <summary>
/// Called when receiving match data from the Nakama server.
/// </summary>
/// <param name="matchState">The incoming match state data.</param>
private void OnReceivedMatchState(IMatchState matchState)
{
    // If the incoming data is not related to this remote player, ignore it and return early.
    if (matchState.UserPresence.SessionId != NetworkData.User.SessionId)
    {
        return;
    }

    // Decide what to do based on the Operation Code of the incoming state data as defined in OpCodes.
    switch (matchState.OpCode)
    {
        case OpCodes.VelocityAndPosition:
            UpdateVelocityAndPositionFromState(matchState.State);
            break;
        case OpCodes.Input:
            SetInputFromState(matchState.State);
            break;
        case OpCodes.Died:
            playerMovementController.PlayDeathAnimation();
            break;
        default:
            break;
    }
}
```

먼저, 해당 개체의 `NetworkData.User.SessionId`을(를) `matchState.UserPresence.SessionId` 값과 비교하여 메시지가 해당 개체에 대해서 수신된 것인지 여부를 확인합니다:

- **일치하지 않을 경우**, 이 메시지를 처리하지 않아도 되고 해당 함수에서 결과가 일찍 반환됩니다.
- **일치할 경우**, `matchState.OpCode`에서 전환을 실시합니다. `OpCode`에 따라 상태를 처리해야 하는 세 개의 함수 중 한 개의 함수로 전달합니다:
    - [위치 및 속도 수신](#receiving-position-and-velocity)
    - [입력 수신](#receiving-input)
    - [플레이어가 사망한 경우](#when-a-player-dies)

### 위치 및 속도 수신

이것을 위해서 `UpdateVelocityAndPositionFromState` 함수를 살펴보겠습니다:

**PlayerNetworkRemoteSync.cs**

```csharp
/// <summary>
/// Updates the player's velocity and position based on incoming state data.
/// </summary>
/// <param name="state">The incoming state byte array.</param>
private void UpdateVelocityAndPositionFromState(byte[] state)
{
    var stateDictionary = GetStateAsDictionary(state);

    playerRigidbody.velocity = new Vector2(float.Parse(stateDictionary["velocity.x"]), float.Parse(stateDictionary["velocity.y"]));

    var position = new Vector3(
        float.Parse(stateDictionary["position.x"]),
        float.Parse(stateDictionary["position.y"]),
        0);

    // Begin lerping to the corrected position.
    lerpFromPosition = playerTransform.position;
    lerpToPosition = position;
    lerpTimer = 0;
    lerpPosition = true;
}
```

여기서, 도움말 함수 `GetStateAsDictionary`을(를) 사용하여 상태를 `Dictionary<string, string>`(으)로 수신하고, 입력되는 `velocity.x`와(과) `velocity.y`의 값을 통해 플레이어의 속도 속성을 업데이트합니다.

다음으로, 수신되는 `position.x` 및 `position.y` 값을 사용하여 새로운 `Vector3`을(를) 생성합니다. 그런 다음, 플레이어의 현재 위치를 `lerpFromPosition`(으)로 설정하고 새로운 위치를 `lerpToPosition`(으)로 설정하여 보간 과정을 시작합니다. 보간 타이머를 재설정하여 `lerpPosition` 값을 `true`(으)로 설정해서 `LateUpdate`에서 발생하는 보간 코드가 실행될 수 있도록 합니다.

### 입력 수신

이것을 위해서 `SetInputFromState` 함수를 살펴보겠습니다:

**PlayerNetworkRemoteSync.cs**

```csharp
/// <summary>
/// Sets the appropriate input values on the PlayerMovementController and PlayerWeaponController based on incoming state data.
/// </summary>
/// <param name="state">The incoming state Dictionary.</param>
private void SetInputFromState(byte[] state)
{
    var stateDictionary = GetStateAsDictionary(state);

    playerMovementController.SetHorizontalMovement(float.Parse(stateDictionary["horizontalInput"]));
    playerMovementController.SetJump(bool.Parse(stateDictionary["jump"]));
    playerMovementController.SetJumpHeld(bool.Parse(stateDictionary["jumpHeld"]));

    if (bool.Parse(stateDictionary["attack"]))
    {
        playerWeaponController.Attack();
    }
}
```

다시 상태를 `Dictionary<string, string>`(으)로 수신하고, 입력되는 상태 데이터에서 `PlayerMovementController`에 적절한 값을 설정합니다. `stateDictionary["attack"]` 값이 `true`인 경우, `Attack` 함수를 호출하여 `PlayerWeaponController`을(를) 업데이트합니다.

마지막으로 `OnDestroy` 함수를 고려합니다. 해당 개체가 파괴된 경우 `OnReceivedMatchState` 핸들러와 같이 더 이상 필요하지 않은 이벤트 리스너를 제거하는 것이 좋습니다:

**PlayerNetworkRemoteSync.cs**

```csharp
/// <summary>
/// Called when this GameObject is being destroyed.
/// </summary>
private void OnDestroy()
{
    if (gameManager != null)
    {
        gameManager.NakamaConnection.Socket.ReceivedMatchState -= OnReceivedMatchState;
    }
}
```

### 플레이어가 사망한 경우

플레이어의 사망을 처리하는 방법을 검토하기 위해서 `Entities` > `Player` > `PlayerHealthController`을(를) 살펴보겠습니다. 플레이어의 체력이 1 미만인 경우 `PlayerDied` 이벤트가 선언되고 `TakeDamage` 함수로 호출됩니다:

**PlayerHealthController.cs**

```csharp
public PlayerDiedEvent PlayerDied;

// ...

/// <summary>
/// Reduces the players health by damage, triggers the PlayerDied event if health is 0 or below.
/// </summary>
/// <param name="damage">The amount of damage the player should receive.</param>
public void TakeDamage(int damage = 1)
{
    // ...

    // If health falls to 0 or below, disable player input controls, play the death animation and fire the PlayerDied event.
    if (health <= 0)
    {
        // ...
        PlayerDied.Invoke(gameObject);
    }
}
```

로컬 플레이어의 사망은 `GameManager` 클래스에서 `OnLocalPlayerDied` 함수를 통해 처리됩니다:

**GameManager.cs**

```csharp
/// <summary>
/// Called when the local player dies.
/// </summary>
/// <param name="player">The local player.</param>
private async void OnLocalPlayerDied(GameObject player)
{
    // Send a network message telling everyone that we died.
    await SendMatchStateAsync(OpCodes.Died, MatchDataJson.Died(player.transform.position));

    // Remove ourself from the players array and destroy our GameObject after 0.5 seconds.
    players.Remove(localUser.SessionId);
    Destroy(player, 0.5f);
}
```

여기서, 네트워크를 통해 메시지를 전송하여 다른 플레이어에게 사망했다는 소식과 위치를 알려줍니다. 그런 다음, `players` 사전에서 제거되고 파괴됩니다.

남아 있는 모든 플레이어는 `OpCodes.Died` 네트워크 동작을 수신하고 응답은 `OnReceivedMatchState` 함수를 통해 처리됩니다:

**GameManager.cs**

```csharp
/// <summary>
/// Called when new match state is received.
/// </summary>
/// <param name="matchState">The MatchState data.</param>
private async Task OnReceivedMatchState(IMatchState matchState)
{
    // Get the local user's session ID.
    var userSessionId = matchState.UserPresence.SessionId;

    // If the matchState object has any state length, decode it as a Dictionary.
    var state = matchState.State.Length > 0 ? System.Text.Encoding.UTF8.GetString(matchState.State).FromJson<Dictionary<string, string>>() : null;

    // Decide what to do based on the Operation Code as defined in OpCodes.
    switch(matchState.OpCode)
    {
        case OpCodes.Died:
            // Get a reference to the player who died and destroy their GameObject after 0.5 seconds and remove them from our players array.
            var playerToDestroy = players[userSessionId];
            Destroy(playerToDestroy, 0.5f);
            players.Remove(userSessionId);

            // If there is only one player left and that us, announce the winner and start a new round.
            if (players.Count == 1 && players.First().Key == localUser.SessionId) {
                AnnounceWinnerAndStartNewRound();
            }
            break;
        case OpCodes.Respawned:
            // Spawn the player at the chosen spawn index.
            SpawnPlayer(currentMatch.Id, matchState.UserPresence, int.Parse(state["spawnIndex"]));
            break;
        case OpCodes.NewRound:
            // Display the winning player's name and begin a new round.
            await AnnounceWinnerAndRespawn(state["winningPlayerName"]);
            break;
        default:
            break;
    }
}
```

여기서, 메시지를 전송한 연결된 사용자의 세션 ID를 참조하여 `userSessionId`(으)로 저장하고, `matchState.State.Length` 속성을 확인하여 네트워크 메시지 내에 상태 데이터가 있는지 확인합니다.

마지막으로, `matchState.OpCode`(으)로 전환하고 필요 시 각각의 항목을 처리합니다.

## 승자 알림

플레이어가 사망할 때마다 `GameObject`을(를) 파괴하고 세션 ID를 기반으로 `players` 사전에서 제거합니다.

다음으로, 다른 플레이어가 남아 있는지 확인합니다. 다른 플레이어가 남아 있는 경우, `AnnounceWinnerAndStartNewRound` 함수가 호출됩니다:

**GameManager.cs**

```csharp
/// <summary>
/// Sends a network message that indicates a player has won and a new round is being started.
/// </summary>
/// <returns></returns>
public async void AnnounceWinnerAndStartNewRound()
{
    // ...
    var winningPlayerName = localDisplayName;

    // Send a network message telling everyone else that we won.
    await SendMatchStateAsync(OpCodes.NewRound, MatchDataJson.StartNewRound(winningPlayerName));

    // Display the winning player message and respawn our player.
    await AnnounceWinnerAndRespawn(winningPlayerName);
}
```

승자가 된 플레이어의 이름을 받아 `OpCodes.NewRound` 동작으로 네트워크 메시지를 전송하여 다른 플레이어에게 대결에서 승리하고 승자가 정해졌다는 사실을 알려줍니다. 그런 다음, `AnnounceWinnerAndRespawn` 함수를 호출합니다:

**GameManager.cs**

```csharp
/// <summary>
/// Displays the winning player message and respawns the player.
/// </summary>
/// <param name="winningPlayerName">The name of the winning player.</param>
private async Task AnnounceWinnerAndRespawn(string winningPlayerName)
{
    // Set the winning player text label.
    WinningPlayerText.text = string.Format("{0} won this round!", winningPlayerName);

    // Wait for 2 seconds.
    await Task.Delay(2000);

    // Reset the winner player text label.
    WinningPlayerText.text = "";

    // Remove ourself from the players array and destroy our player.
    players.Remove(localUser.SessionId);
    Destroy(localPlayer);

    // Choose a new spawn position and spawn our local player.
    var spawnIndex = Random.Range(0, SpawnPoints.transform.childCount - 1);
    SpawnPlayer(currentMatch.Id, localUser, spawnIndex);

    // Tell everyone where we respawned.
    SendMatchState(OpCodes.Respawned, MatchDataJson.Respawned(spawnIndex));
}
```

이렇게 하면 승자 플레이어 메시지가 표시되고, 약간의 지연(2초) 후에 메시지를 제거합니다. 그런 다음, 로컬 플레이어 `GameObject`을(를) 파괴하고 임의의 장소에서 다시 생성하기 전에 `players` 사전을 기반으로 제거합니다.
