# Ninja Battle

**URL:** https://heroiclabs.com/docs/zh/nakama/tutorials/unity/ninja-battle/
**Summary:** Ninja Battle 采用 Unity 作为开发工具，是一款自上而下的 2D 实时战斗游戏，玩家可以控制跑过网格时会丢下致命铁蒺藜的冲刺忍者。

---


# Unity Ninja Battle 教程

Ninja Battle 是由 [Heroic Games Grant](https://heroiclabs.com/blog/announcements/announcing-heroic-games-grant-recipients/) 赞助的一个开源游戏项目。Ninja Battle 使用 Unity 开发，是一款自上而下的 2D 实时战斗游戏，玩家可以控制跑过网格时会丢下致命铁蒺藜的冲刺的忍者。

![Ninja Battle](./images/ninja_battle.jpeg)

此游戏及本教程集中展示以下 Nakama 功能：

* [身份验证](../../../concepts/authentication/)
* [会话](../../../concepts/session/)
* [用户](../../../concepts/user-accounts/)
* [匹配程序](../../../concepts/multiplayer/matchmaker/)
* [权威多人游戏](../../../concepts/multiplayer/authoritative/)

## 前提条件

为轻松学习本教程，请执行以下操作后再继续：

- [安装 Nakama](../../../getting-started/install/docker/)
- [下载](https://unity3d.com/get-unity/download)和安装 Unity
- [安装 Nakama Unity SDK](https://github.com/heroiclabs/nakama-unity)
- [下载 Ninja Battle Unity 项目](https://github.com/heroiclabs/ninja-battle-nakama/)

## 项目结构

Ninja Battle 项目包括七个[场景](https://github.com/heroiclabs/ninja-battle-nakama/tree/main/NinjaBattle/Assets/Scenes)：

{{< table name="nakama.tutorials.unity.ninja-battle.project-structure" >}}

### 辅助函数

该项目还包含一个辅助函数小子集，这是一些 Nakama 功能的示例实现。[`NakamaManager`](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Nakama/NakamaManager.cs) 是这些函数的基础脚本，用于用户登录和注销，并将 RPC 发送到服务器。

## 身份验证

`NakamaManager` 脚本可让您使用三种类型的登录：

```csharp
// Generate a random UDID and save it to PlayerPrefs
NakamaManager.Instance.LoginWithUdid();
// Use the device's unique identifier
NakamaManager.Instance.LoginWithDevice();
// Use a custom ID to handle authentication
NakamaManager.Instance.LoginWithCustomId();
```

您可以在 **[NakamaAutoLogin](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Nakama/NakamaAutoLogin.cs)** 中看到 Ninja Battle 实现：

```csharp
using UnityEngine;

namespace Nakama.Helpers
{
    public class NakamaAutoLogin : MonoBehaviour
    {
        #region FIELDS

        [SerializeField] private float retryTime = 5f;

        #endregion

        #region BEHAVIORS

        private void Start()
        {
            NakamaManager.Instance.onLoginFail += LoginFailed;
            TryLogin();
        }

        private void OnDestroy()
        {
            NakamaManager.Instance.onLoginFail -= LoginFailed;
        }

        private void TryLogin()
        {
            NakamaManager.Instance.LoginWithUdid();
        }

        private void LoginFailed()
        {
            Invoke(nameof(TryLogin), retryTime);
        }

        #endregion
    }
}
```

[`NakamaEvents`](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Nakama/NakamaEvents.cs) 组件向 Unity 检查器公开所有 `NakamaManager` 事件，这些事件帮助处理 Ninja Battle 的游戏逻辑。例如，“Splash”（启动画面）场景在接收 `onLoginSuccess` 事件时执行场景更改。

### 设置用户名

Ninja Battle 可让玩家加入比赛并立即开始游戏，他们不需要专门为自己创建用户名。对于新帐户，将为不想设置自定义名称的玩家生成随机的双单词名称。

**SetDisplayName.cs**:

```csharp
using TMPro;
using UnityEngine;
using Nakama.Helpers;

namespace NinjaBattle.General
{
    public class SetDisplayName : MonoBehaviour
    {
        #region FIELDS

        private const float delay = 1f;

        [SerializeField] private TMP_InputField inputField = null;
        [SerializeField] private string[] firstPart = null;
        [SerializeField] private string[] secondPart = null;

        private NakamaUserManager nakamaUserManager = null;

        #endregion

        #region BEHAVIORS

        private void Start()
        {
            nakamaUserManager = NakamaUserManager.Instance;
            nakamaUserManager.onLoaded += ObtainName;
            inputField.onValueChanged.AddListener(ValueChanged);
            if (nakamaUserManager.LoadingFinished)
                ObtainName();
        }

        private void OnDestroy()
        {
            inputField.onValueChanged.RemoveListener(ValueChanged);
            nakamaUserManager.onLoaded -= ObtainName;
        }

        private void ObtainName()
        {
            if (string.IsNullOrEmpty(nakamaUserManager.DisplayName))
                inputField.text = firstPart[Random.Range(0, firstPart.Length)] + secondPart[Random.Range(0, secondPart.Length)];
            else
                inputField.text = nakamaUserManager.DisplayName;
        }

        private void ValueChanged(string newValue)
        {
            CancelInvoke(nameof(UpdateName));
            Invoke(nameof(UpdateName), delay);
        }

        private void UpdateName()
        {
            if (inputField.text != nakamaUserManager.DisplayName)
                nakamaUserManager.UpdateDisplayName(inputField.text);
        }

        #endregion
    }
}
```

玩家可以在需要时更改用户名。这通过 `NakamaUserManager` 中的 `UpdateDisplayName` 函数处理。

```csharp
// ...
public async void UpdateDisplayName(string displayName)
{
    await NakamaManager.Instance.Client.UpdateAccountAsync(NakamaManager.Instance.Session, null, displayName);
}
// ...
```

## 游戏玩法

Ninja Battle 中的游戏逻辑分为两个部分，一个通过 Unity 客户端处理，另外一个通过使用服务器运行时的[自定义 RPC](../../../server-framework/introduction/#functionality) 处理。

玩家在[主屏](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scenes/2-Home.unity)中按 `Battle` 按钮，触发 `FindMatch` 函数：

```csharp
private void FindMatch()
{
    button.interactable = false;
    MultiplayerManager.Instance.JoinMatchAsync();
}
```

[MultiplayerManager](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Nakama/MultiplayerManager.cs) 注册即将到来的比赛状态：

```csharp
NakamaManager.Instance.Socket.ReceivedMatchState += Receive;
```

使用 `NakamaManager` 调用 `JoinOrCreateMatchRpc` RPC：

```csharp
private const string JoinOrCreateMatchRpc = "JoinOrCreateMatchRpc";
// ...
IApiRpc rpcResult = await NakamaManager.Instance.SendRPC(JoinOrCreateMatchRpc);
// ...
```

然后在服务器上执行 `joinOrCreateMatch` RPC。此函数查找玩家可加入的任何打开的比赛，如果找不到，则创建一个新的比赛：

```typescript
let joinOrCreateMatch: nkruntime.RpcFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, payload: string): string
{
    let matches: nkruntime.Match[];
    const MatchesLimit = 1;
    const MinimumPlayers = 0;
    var label: MatchLabel = { open: true }
    matches = nakama.matchList(MatchesLimit, true, JSON.stringify(label), MinimumPlayers, MaxPlayers - 1);
    if (matches.length > 0) {
        return matches[0].matchId;
    }

    return nakama.matchCreate(MatchModuleName);
}
```

客户端接收服务器返回的 `matchId`，并发送加入请求：

```csharp
string matchId = rpcResult.Payload;
match = await NakamaManager.Instance.Socket.JoinMatchAsync(matchId);
```

调用 `onMatchJoin` 事件，订阅此事件的 `GameManager` 切换到[大厅场景](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scenes/3-Lobby.unity)：

```csharp
onMatchJoin?.Invoke();

// ...

MultiplayerManager.Instance.onMatchJoin += JoinedMatch;
...
private void JoinedMatch()
{
    ResetPlayerWins();
    GoToLobby();
}
...
private void GoToLobby()
{
    SceneManager.LoadScene((int)Scenes.Lobby);
}
```

## 客户端逻辑

此部分详细介绍 Ninja Battle 的客户端逻辑。

### MultiplayerManager

[`MultiplayerManager`](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Nakama/MultiplayerManager.cs) 处理[加入比赛](#gameplay)的逻辑，并发送和接收服务器上此比赛的消息。这些消息通过以下 `OperationCodes` 描述：

{{< table name="nakama.tutorials.unity.ninja-battle.op-codes" >}}

`MultiplayerManager` 接收所有消息，并按照订阅分发消息，例如：

```csharp
MultiplayerManager.Instance.Subscribe(MultiplayerManager.Code.PlayerInput, ReceivedPlayerInput);
```

要发送消息，发送可以序列化的对象：

```csharp
private void SendData(int tick, Direction direction)
{
   MultiplayerManager.Instance.Send(MultiplayerManager.Code.PlayerInput, new InputData(tick, (int)direction));
}
```

要接收消息，请使用“MultiplayerManager”反序列化到所需的类：

```csharp
private void ReceivedPlayerInput(MultiplayerMessage message)
{
   InputData inputData = message.GetData<InputData>();
   SetPlayerInput(GetPlayerNumber(message.SessionId), inputData.Tick, (Direction)inputData.Direction);
}
```

### 多人游戏标识

此脚本保留每个玩家的唯一 ID，此处为 `SessionId`：

**MultiplayerIdentity.cs**:
```csharp
using UnityEngine;

namespace Nakama.Helpers
{
    public partial class MultiplayerIdentity : MonoBehaviour
    {
        #region FIELDS

        private static int currentId = 0;

        #endregion

        #region PROPERTIES

        public string Id { get; private set; }
        public bool IsLocalPlayer { get => MultiplayerManager.Instance.Self != null && MultiplayerManager.Instance.Self.SessionId == Id; }

        #endregion

        #region BEHAVIORS

        private void Awake()
        {
            AssignIdentity();
        }

        private void AssignIdentity()
        {
            Id = currentId++.ToString();
        }

        public void SetId(string id)
        {
            Id = id;
        }

        public static void ResetIds()
        {
            currentId = default(int);
        }

        #endregion
    }
}
```

### 生成玩家

玩家的生成由 [Map](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/NinjaBattle/Assets/Scripts/Game/Map.cs) 类处理，接收每个玩家，将其放在不同的起始位置上：

**Map.cs**:
```csharp
// ...
public void InstantiateNinja(int playerNumber, SpawnPoint spawnPoint, string sessionId)
{
    Ninja ninja = Instantiate(ninjaPrefab, transform);
    ninja.Initialize(spawnPoint, playerNumber, this, sessionId);
    Ninjas.Add(ninja);
    if (MultiplayerManager.Instance.Self.SessionId == sessionId)
        gameCamera.SetStartingPosition(spawnPoint.Coordinates);
}
// ...
```

### 回滚

Ninja Battle 使用了回滚网络代码的基本实现。您可以[在此](https://ki.infil.net/w02-netcode.html)进一步了解此概念。

`RollbackVar` 使用字典处理时间线信息的保存，`int` 代表游戏循环的每个节拍，`T` 为任何所需的类型。

**RollbackVar.cs**:
```csharp
using System.Collections.Generic;

namespace NinjaBattle.Game
{
    public class RollbackVar<T>
    {
        #region FIELDS

        private Dictionary<int, T> history = new Dictionary<int, T>();

        #endregion

        #region PROPERTIES

        public T this[int tick]
        {
            get
            {
                return history.ContainsKey(tick) ? history[tick] : default(T);
            }

            set
            {
                if (history.ContainsKey(tick))
                    history[tick] = value;
                else
                    history.Add(tick, value);
            }
        }

        #endregion

        #region BEHAVIORS

        public bool HasValue(int tick)
        {
            return history.ContainsKey(tick);
        }

        public T GetLastValue(int tick)
        {
            for (; tick >= 0; tick--)
                if (HasValue(tick))
                    return history[tick];

            return default(T);
        }

        public void EraseFuture(int tick)
        {
            List<int> keysToErase = new List<int>();
            foreach (KeyValuePair<int, T> keyValuePair in history)
                if (keyValuePair.Key > tick)
                    keysToErase.Add(keyValuePair.Key);

            foreach (int i in keysToErase)
                history.Remove(i);
        }

        #endregion
    }
}
```

## 服务器端逻辑

本节详细介绍 Ninja Battle 中的服务器端游戏逻辑。

### RPC

对于 Ninja Battle，我们注册了一个 RPC，这样我们就可以从比赛之外的客户端向服务器发送信息。这在我们的 `main.ts` 文件中的 `InitModule` 内完成：

```typescript
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer)
{
    initializer.registerRpc("JoinOrCreateMatchRpc", joinOrCreateMatch);
}

let joinOrCreateMatch: nkruntime.RpcFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, payload: string): string
{
    return "response";
}
```

然后可以从任何具有开放套接字连接的客户端调用此 RPC：

```csharp
return await client.RpcAsync(session, rpc, payload);
```

### 比赛处理程序

[`match_handler`](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/Nakama/src/match_handler.ts) 用于自始至终管理比赛。就像上述 RPC，它也在我们的 `main.ts` 文件的 `InitModule` 内注册：

```typescript
const JoinOrCreateMatchRpc = "JoinOrCreateMatchRpc";
const LogicLoadedLoggerInfo = "Custom logic loaded.";
const MatchModuleName = "match";

function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer)
{
    initializer.registerRpc(JoinOrCreateMatchRpc, joinOrCreateMatch);
    initializer.registerMatch(MatchModuleName, {
        matchInit,
        matchJoinAttempt,
        matchJoin,
        matchLeave,
        matchLoop,
        matchTerminate,
        matchSignal
    });

    logger.info(LogicLoadedLoggerInfo);
}
```

所有比赛逻辑都保留在字典中的 `match_handler`，字典可以转换为界面。在此，我们使用一个叫做 `GameState` 的界面：

**match_handler.ts**:
```typescript
interface GameState
{
    players: Player[]
    playersWins: number[]
    roundDeclaredWins: number[][]
    roundDeclaredDraw: number[]
    scene: Scene
    countdown: number
    endMatch: boolean
}
```

#### 比赛循环

每个节拍都调用 `matchLoop` 函数。在每个节拍上，在此期间发送的所有客户端消息都作为一个列表接收。使用此列表，您可以决定如何处理消息：将消息发送回客户端或执行自定义逻辑。

**match_handler.ts**:
```typescript
// ...
let matchLoop: nkruntime.MatchLoopFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, messages: nkruntime.MatchMessage[])
{
    let gameState = state as GameState;
    processMessages(messages, gameState, dispatcher, nakama);
    processMatchLoop(gameState, nakama, dispatcher, logger);
    return gameState.endMatch ? null : { state: gameState };
}
// ...
```

在由 `processMessages` 函数处理时，如果消息代码包含自定义逻辑，则调用函数，否则使用默认逻辑将消息发送到所有客户端：

**match_handler.ts**:
```typescript
// ...
function processMessages(messages: nkruntime.MatchMessage[], gameState: GameState, dispatcher: nkruntime.MatchDispatcher, nakama: nkruntime.Nakama): void
{
    for (let message of messages)
    {
        let opCode: number = message.opCode;
        if (MessagesLogic.hasOwnProperty(opCode)) {
            MessagesLogic[opCode](message, gameState, dispatcher, nakama);
        } else {
            messagesDefaultLogic(message, gameState, dispatcher);
        }
    }
}

function messagesDefaultLogic(message: nkruntime.MatchMessage, gameState: GameState, dispatcher: nkruntime.MatchDispatcher): void
{
    dispatcher.broadcastMessage(message.opCode, message.data, null, message.sender);
}
// ...
```

对于 Ninja Battle，所有自定义逻辑都在 [`consts.ts`](https://github.com/heroiclabs/ninja-battle-nakama/blob/main/Nakama/src/consts.ts) 文件中注册。

### 时间计数器

Ninja Battle `Lobby` 和 `RoundResults` 场景使用由服务器运行的倒计时，以等待下一个场景：

```typescript
function matchLoopLobby(gameState: GameState, nakama: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher): void
{
    if (gameState.countdown > 0 && getPlayersCount(gameState.players) > 1)
    {
        gameState.countdown--;
        if (gameState.countdown == 0)
        {
            gameState.scene = Scene.Battle;
            dispatcher.broadcastMessage(OperationCode.ChangeScene, JSON.stringify(gameState.scene));
            dispatcher.matchLabelUpdate(JSON.stringify({ open: false }));
        }
    }
}
```

倒计时在 `matchJoin` 上设置，其有一个期间，其中秒数为服务器节拍率的倍数：

```typescript
gameState.countdown = DurationLobby * TickRate;
```

### 确定比赛赢家

由于在 Ninja Battle 中使用了回滚网码，客户端可能会发送错误的获胜者消息。为了让服务器信任客户端消息并确定胜者，所有客户端消息都必须声明在同一时间点发生的相同结果：

```typescript
function playerWon(message: nkruntime.MatchMessage, gameState: GameState, dispatcher: nkruntime.MatchDispatcher, nakama: nkruntime.Nakama): void
{
    if (gameState.scene != Scene.Battle || gameState.countdown > 0)
        return;

    let data: PlayerWonData = JSON.parse(nakama.binaryToString(message.data));
    let tick: number = data.tick;
    let playerNumber: number = data.playerNumber;
    if (gameState.roundDeclaredWins[tick] == undefined)
        gameState.roundDeclaredWins[tick] = [];

    if (gameState.roundDeclaredWins[tick][playerNumber] == undefined)
        gameState.roundDeclaredWins[tick][playerNumber] = 0;

    gameState.roundDeclaredWins[tick][playerNumber]++;
    if (gameState.roundDeclaredWins[tick][playerNumber] < getPlayersCount(gameState.players))
        return;

    gameState.playersWins[playerNumber]++;
    gameState.countdown = DurationBattleEnding * TickRate;
    dispatcher.broadcastMessage(message.opCode, message.data, null, message.sender);
}
```

### 结束比赛

当任何一名玩家取得三次胜利或所有玩家都断开连接时，比赛结束。为结束比赛，在任何比赛函数上返回 `null`：

```typescript
return gameState.endMatch ? null : { state: gameState };
```

### 增加用户奖杯

在 Ninja Battle 中，获胜的玩家在比赛结束时获得奖杯。要存储玩家的奖杯，我们必须首先为该用户执行存储读取，将现有值增加所需的量，然后使用新值执行存储写入。

```typescript
let storageReadRequests: nkruntime.StorageReadRequest[] = [{
    collection: CollectionUser,
    key: KeyTrophies,
    userId: winner.presence.userId
}];

let result: nkruntime.StorageObject[] = nakama.storageRead(storageReadRequests);
var trophiesData: TrophiesData = { amount: 0 };
for (let storageObject of result)
{
    trophiesData = <TrophiesData>storageObject.value;
    break;
}

trophiesData.amount++;
let storageWriteRequests: nkruntime.StorageWriteRequest[] = [{
    collection: CollectionUser,
    key: KeyTrophies,
    userId: winner.presence.userId,
    value: trophiesData
}];

nakama.storageWrite(storageWriteRequests);
```
