Unity Ninja Battle 튜토리얼 #

Ninja Battle은 Heroic Games Grant가 후원하는 오픈 소스 게임 프로젝트입니다. Unity를 사용해서 개발한 Ninja Battle은 실시간 탑다운 2D 배틀 게임으로, 플레이어는 격자에서 치명적인 표창을 던지며 달리는 닌자를 제어합니다.

Ninja Battle
Ninja Battle

게임과 튜토리얼에서는 다음의 Nakama 기능이 강조됩니다:

필수 조건 #

튜토리얼을 쉽게 따라하기 위해서는 진행하기 전에 다음을 실시합니다:

프로젝트 구조 #

Ninja Battle 프로젝트는 7개의 장면으로 구성됩니다:

SceneDescription
Initializer모든 싱글톤을 초기화합니다. 세션당 한 번만 실행합니다.
Splash인증/로그인을 처리합니다.
Home플레이어가 매치를 시작하고 이름을 변경하고 트로피를 볼 수 있는 Ninja Battle의 주 메뉴.
Lobby플레이어가 매치에 참가해 상대를 기다리고 있습니다.
Battle게임 플레이가 이루어지는 곳.
RoundResults매치에서 각 플레이어가 승리한 라운드 수를 표시합니다.
FinalResults매치의 승자를 표시합니다.

도움말 함수 #

이 프로젝트에는 일부 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(으)로 설명할 수 있습니다:

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

대결 종료 #

플레이어가 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);