Unity Ninja Battle 教程 #

Ninja Battle 是由 Heroic Games Grant 赞助的一个开源游戏项目。Ninja Battle 使用 Unity 开发,是一款自上而下的 2D 实时战斗游戏,玩家可以控制跑过网格时会丢下致命铁蒺藜的冲刺的忍者。

Ninja Battle
Ninja Battle

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

前提条件 #

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

项目结构 #

Ninja Battle 项目包括七个场景

SceneDescription
Initializer初始化所有单例。每个会话仅运行一次。
Splash处理身份验证/登录。
HomeNinja Battle 的主菜单,玩家可以在这里开始比赛、更改名称和查看奖杯。
Lobby玩家已加入比赛,正在等待对手。
Battle选择游戏玩法。
RoundResults显示比赛中每位玩家获胜的回合数。
FinalResults显示比赛获胜者。

辅助函数 #

该项目还包含一个辅助函数小子集,这是一些 Nakama 功能的示例实现。NakamaManager 是这些函数的基础脚本,用于用户登录和注销,并将 RPC 发送到服务器。

身份验证 #

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

1
2
3
4
5
6
// 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 中看到 Ninja Battle 实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 组件向 Unity 检查器公开所有 NakamaManager 事件,这些事件帮助处理 Ninja Battle 的游戏逻辑。例如,“Splash”(启动画面)场景在接收 onLoginSuccess 事件时执行场景更改。

设置用户名 #

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

SetDisplayName.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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 函数处理。

1
2
3
4
5
6
// ...
public async void UpdateDisplayName(string displayName)
{
    await NakamaManager.Instance.Client.UpdateAccountAsync(NakamaManager.Instance.Session, null, displayName);
}
// ...

游戏玩法 #

Ninja Battle 中的游戏逻辑分为两个部分,一个通过 Unity 客户端处理,另外一个通过使用服务器运行时的自定义 RPC 处理。

玩家在主屏中按 Battle 按钮,触发 FindMatch 函数:

1
2
3
4
5
private void FindMatch()
{
    button.interactable = false;
    MultiplayerManager.Instance.JoinMatchAsync();
}

MultiplayerManager 注册即将到来的比赛状态:

1
NakamaManager.Instance.Socket.ReceivedMatchState += Receive;

使用 NakamaManager 调用 JoinOrCreateMatchRpc RPC:

1
2
3
4
private const string JoinOrCreateMatchRpc = "JoinOrCreateMatchRpc";
// ...
IApiRpc rpcResult = await NakamaManager.Instance.SendRPC(JoinOrCreateMatchRpc);
// ...

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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,并发送加入请求:

1
2
string matchId = rpcResult.Payload;
match = await NakamaManager.Instance.Socket.JoinMatchAsync(matchId);

调用 onMatchJoin 事件,订阅此事件的 GameManager 切换到大厅场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
onMatchJoin?.Invoke();

// ...

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

客户端逻辑 #

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

MultiplayerManager #

MultiplayerManager 处理加入比赛的逻辑,并发送和接收服务器上此比赛的消息。这些消息通过以下 OperationCodes 描述:

CodeNameDescription
0Players新玩家加入比赛时,会向新玩家发送玩家列表。
1PlayerJoined会向比赛中的所有其他玩家发送新玩家的显示名称。
2PlayerInput客户端向所有其他客户端发送的玩家的方向输入。
3PlayerWon比赛获胜者的客户端消息。所有客户端报告的获胜者必须一致,服务器才会信任这个消息。
4Draw比赛平局的客户端消息。所有客户端报告的结果必须一致,服务器才会信任这个消息。
5ChangeScene服务器向所有客户端发送的消息,指示要换到哪个场景。

MultiplayerManager 接收所有消息,并按照订阅分发消息,例如:

1
MultiplayerManager.Instance.Subscribe(MultiplayerManager.Code.PlayerInput, ReceivedPlayerInput);

要发送消息,发送可以序列化的对象:

1
2
3
4
private void SendData(int tick, Direction direction)
{
   MultiplayerManager.Instance.Send(MultiplayerManager.Code.PlayerInput, new InputData(tick, (int)direction));
}

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

1
2
3
4
5
private void ReceivedPlayerInput(MultiplayerMessage message)
{
   InputData inputData = message.GetData<InputData>();
   SetPlayerInput(GetPlayerNumber(message.SessionId), inputData.Tick, (Direction)inputData.Direction);
}

多人游戏标识 #

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

MultiplayerIdentity.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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 类处理,接收每个玩家,将其放在不同的起始位置上:

Map.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ...
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 使用了回滚网络代码的基本实现。您可以在此进一步了解此概念。

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

RollbackVar.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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 内完成:

1
2
3
4
5
6
7
8
9
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:

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

比赛处理程序 #

match_handler 用于自始至终管理比赛。就像上述 RPC,它也在我们的 main.ts 文件的 InitModule 内注册:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface GameState
{
    players: Player[]
    playersWins: number[]
    roundDeclaredWins: number[][]
    roundDeclaredDraw: number[]
    scene: Scene
    countdown: number
    endMatch: boolean
}

比赛循环 #

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

match_handler.ts:

1
2
3
4
5
6
7
8
9
// ...
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ...
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 文件中注册。

时间计数器 #

Ninja Battle LobbyRoundResults 场景使用由服务器运行的倒计时,以等待下一个场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 上设置,其有一个期间,其中秒数为服务器节拍率的倍数:

1
gameState.countdown = DurationLobby * TickRate;

确定比赛赢家 #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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

1
return gameState.endMatch ? null : { state: gameState };

增加用户奖杯 #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);