Unity Ninja Battle 教程
# Ninja Battle 是由 Heroic Games Grant 赞助的一个开源游戏项目。Ninja Battle 使用 Unity 开发,是一款自上而下的 2D 实时战斗游戏,玩家可以控制跑过网格时会丢下致命铁蒺藜的冲刺的忍者。
Ninja Battle
此游戏及本教程集中展示以下 Nakama 功能:
前提条件
# 为轻松学习本教程,请执行以下操作后再继续:
项目结构
# Ninja Battle 项目包括七个场景 :
辅助函数
# 该项目还包含一个辅助函数小子集,这是一些 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
描述:
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 Lobby
和 RoundResults
场景使用由服务器运行的倒计时,以等待下一个场景:
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 );