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 );