Unity Ninja Battle 튜토리얼
# Ninja Battle은 Heroic Games Grant 가 후원하는 오픈 소스 게임 프로젝트입니다. Unity를 사용해서 개발한 Ninja Battle은 실시간 탑다운 2D 배틀 게임으로, 플레이어는 격자에서 치명적인 표창을 던지며 달리는 닌자를 제어합니다.
Ninja Battle
게임과 튜토리얼에서는 다음의 Nakama 기능이 강조됩니다:
필수 조건
# 튜토리얼을 쉽게 따라하기 위해서는 진행하기 전에 다음을 실시합니다:
프로젝트 구조
# Ninja Battle 프로젝트는 7개의 장면 으로 구성됩니다:
도움말 함수
# 이 프로젝트에는 일부 Nakama 기능의 실행 예시로서 도움말 함수 기능을 하는 세트가 포함되어 있습니다. NakamaManager
은 해당 함수에 대한 기본적인 스크립트이며, 사용자 로그인 및 로그아웃을 실시하고 서버에 RPC를 전송하는 데 사용됩니다.
인증
# NakamaManager
스크립트를 통해 3가지 유형의 로그인을 사용할 수 있습니다:
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
구성 요소는 Ninja Battle 게임 로직을 도와주는 이벤트를 포함한 모든 NakamaManager
이벤트를 Unity 검사자에 노출시킵니다. 예를 들어, 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.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 );
}
대결 종료
# 플레이어가 3회 승리를 달성하거나 모든 플레이어가 연결에서 해제된 경우 대결이 종료됩니다. 대결을 종료하려면, 대결 함수에서 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 );