Amazon GameLift Realtime Server Workshop 진행 해보기- Part.3 Puzzle Game 만들기

An mihyang
Cloud Villains
Published in
88 min readJun 30, 2023

앞서 진행한 실시간 서버 생성에 이어서 이제 Puzzle 게임을 만들어 보도록 하겠습니다.

워크샵의 출처는 아래와 같습니다.
https://catalog.us-east-1.prod.workshops.aws/workshops/bccf7c14-8f6f-441b-a4ff-fd6a2b402892/ja-JP/20-puzzle

1. 초기화 설정

1.1. GameLift에 액세스하는 사용자 만들기

1.1.1. 정책 생성

Console 서비스 검색 필드에 IAM을 입력한 다음 IAM 콘솔을 엽니다.

정책 → 정책 생성을 클릭하여 새 정책을 생성합니다.

JSON 선택 후 아래 내용을 정책에 추가합니다. 완료 되었으면 다음을 클릭하여 다음 단계로 넘어갑니다.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"gamelift:CreateGameSession",
"gamelift:DescribeGameSessionDetails",
"gamelift:CreatePlayerSession",
"gamelift:SearchGameSessions"
],
"Resource": "*"
}
]
}

정책 이름을 GameLiftClientServicePolicy로 설정하고 정책 생성을 클릭하여 생성을 완료합니다.

1.1.2. 사용자 생성

다음으로 사용자 → 사용자 추가를 클릭하여 사용자를 생성합니다.

사용자 이름을 PuzzleGameLiftUser로 설정하고 다음 단계로 넘어갑니다.

권한 옵션에서 직접 정책 연결을 선택한 후 1.1.1 단계에서 생성한 정책(GameLiftClientServicePolicy)을 선택하여 연결합니다.

생성 내용 검토 후에 사용자 생성 버튼을 클릭하여 생성을 완료합니다.

1.1.3. 사용자 액세스 키 생성

생성된 PuzzleGameLiftUser 사용자 클릭 후 보안 자격 증명 →액세스 키 만들기 버튼을 클릭합니다.

Unity에서 해당 키를 이용하므로 AWS 외부에서 실행되는 애플리케이션 선택 후 다음을 클릭합니다.

태그 설정은 선택 사항이므로 이번 단계에서는 설정하지 않도록 하겠습니다. 액세스키 만들기 버튼 클릭합니다.

우측 하단의 .csv 파일 다운로드 버튼을 클릭하여 파일을 다운로드합니다.

해당 파일이 외부에 공개되지 않도록 주의하세요.

1.2. Unity 초기화

참고: Unity 기본 배우기

1.2.1. 패키지 다운로드

아래 링크를 통해 패키지를 다운로드합니다.

Download

1.2.2. 초기 설정

앞서 진행한 1.사전 준비의 1.3 단계에서 생성한 Unity 프로젝트를 오픈한 후에 다운로드 받은 패키지를 가져옵니다.

상단 메뉴에서 Asset → Import Package → Custom Package를 선택합니다.

1.2.1 단계에서 다운로드 받은 파일을 선택합니다.

전체 파일을 체크한 후에 Import 버튼을 클릭하여 패키지를 가져옵니다.

아래에서 Project → Assets 폴더 하위에 있는 Scenes를 선택합니다.

상단 메뉴에서 File → Build Settings를 클릭합니다.

Lobby와 Gameplay Scene 선택 후 드래그하여 Scenes In Build에 추가합니다.

Lobby가 먼저 실행되도록 Lobby가 Gameplay 앞에 오도록 설정합니다.

Unity는 Scenes In Build 창에서 선택한 모든 씬을 빌드합니다. 또한, 목록에 나타나는 순서대로 빌드를 진행하므로 순서에 유의하도록 합니다.

Build Setting의 왼쪽 하단에서 Play Settings을 클릭합니다.

Player → Other Settings → API Compatibility Level 을 .NET 4.x로 변경합니다.

상단 메뉴에서 File -> Save Project를 변경된 설정을 저장합니다.

아래 Project → Scenes에서 LobbyScene을 더블 클릭합니다.

Game 패널을 클릭한 후에 시작 버튼(►)을 눌러 게임을 시작합니다.

지금은 설정이 다 되지 않았기 때문에 게임이 정상적으로 실행되지 않습니다.

이제부터 게임이 정상적으로 실행되도록 설정을 해 보도록 하겠습니다.

1.3. 액세스 키 설정

1.3.1. 자격 증명 정보 설정

Hierarchy 뷰에서 LobbyManager 게임 객체를 선택합니다.

1.1 단계에서 사용자 액세스 키 생성 후 다운로드한 credentials.csv 파일을 열고 Inspector 뷰의 AWS Config(Script) 각 항목에 해당하는 정보를 기입합니다.

별칭(alias) ID 확인

  • Region : GameLift Fleet을 생성한 리전
  • Access Key Id : csv 파일의 Access key ID 참조
  • Secret Access Key : csv 파일의 Secret access key 참조
  • Game Lift Alias Id : 별칭 ID

우측의 ⋮ 버튼을 클릭한 후 Edit Script를 통해 AwsConfig.cs 파일의 코드를 확인해 볼 수 있습니다.

또는 아래 프로젝트에서 파일 명 검색 후 더블 클릭을 통해 코드를 확인할 수 있습니다.

Unity는 입력한 값을 자동으로 해당 변수에 할당합니다.

namespace Lobby
{
using UnityEngine;

public sealed class AwsConfig : MonoBehaviour
{
public AwsRegionType Region;
public string AccessKeyId;
public string SecretAccessKey;
public string GameLiftAliasId;

public bool IsValid() =>
IsValid(AccessKeyId, nameof(AccessKeyId)) &
IsValid(SecretAccessKey, nameof(SecretAccessKey)) &
IsValid(GameLiftAliasId, nameof(GameLiftAliasId));

static bool IsValid(string value, string name)
{
if (!string.IsNullOrWhiteSpace(value)) return true;
Debug.LogError($"{nameof(AwsConfig)}.{name} not set");
return false;
}
}
}

1.4. GameLift Client 초기화

LobbyManager.cs 파일을 열고 코드를 확인해보면 설정한 AwsConfig의 내용이 로드되는 것을 볼 수 있습니다.

아래의 코드로 수정 후에 저장합니다. (WIN: ctrl+s, MAC: cmd+s)

  • 추가된 부분: 3, 18, 28–29, 34 행
namespace Lobby
{
using Amazon.GameLift;
using UnityEngine;
using UnityEngine.SceneManagement;
using Gameplay;

[RequireComponent(typeof(AwsConfig))]
public sealed class LobbyManager : MonoBehaviour
{
AwsConfig m_Config;

void Awake()
{
m_Config = GetComponent<AwsConfig>();
}

AmazonGameLiftClient m_GameLift;

void OnEnable()
{
if (!m_Config.IsValid())
{
enabled = false;
return;
}

m_GameLift = new AmazonGameLiftClient(m_Config.AccessKeyId,
m_Config.SecretAccessKey, m_Config.Region.ToEndpoint());
}

void OnDisable()
{
m_GameLift?.Dispose();
}

public void LocalPlay()
{
NetworkManager.PlayerSession = null;
SceneManager.LoadScene("Gameplay");
}
}
}

2. Lobby 만들기

이번 단계에서는 게임을 위해 사용자를 매칭 할 수 있는 방을 생성합니다.

2.1. 게임룸 만들기

LobbyManager.cs 파일을 열어 GameLift로 CreateGameSession을 비동기적으로 호출하는 코드를 추가합니다.

  • 추가된 부분: 4, 6, 9, 40–52행
namespace Lobby
{
using Amazon.GameLift;
using Amazon.GameLift.Model;
using Gameplay;
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
using System.Threading.Tasks;

[RequireComponent(typeof(AwsConfig))]
public sealed class LobbyManager : MonoBehaviour
{
AwsConfig m_Config;

void Awake()
{
m_Config = GetComponent<AwsConfig>();
}

AmazonGameLiftClient m_GameLift;

void OnEnable()
{
if (!m_Config.IsValid())
{
enabled = false;
return;
}

m_GameLift = new AmazonGameLiftClient(m_Config.AccessKeyId,
m_Config.SecretAccessKey, m_Config.Region.ToEndpoint());
}

void OnDisable()
{
m_GameLift?.Dispose();
}

public async Task<PlayerSession> CreateRoom(string roomName = null)
{
Debug.Log("CreateRoom");
if (string.IsNullOrEmpty(roomName)) roomName = Guid.NewGuid().ToString();
var request = new CreateGameSessionRequest
{
AliasId = m_Config.GameLiftAliasId,
MaximumPlayerSessionCount = 2,
Name = roomName
};
var response = await m_GameLift.CreateGameSessionAsync(request);
return await JoinRoom(response.GameSession.GameSessionId);
}

public void LocalPlay()
{
NetworkManager.PlayerSession = null;
SceneManager.LoadScene("Gameplay");
}
}
}

server.js 파일을 열어 로그 처리를 위해 코드를 추가합니다.

  • 추가된 부분: 7, 36–37
'use strict';
const version = '0.1.0';
const util = require('util');

var session; // The Realtime server session object
var logger; // Log at appropriate level via .info(), .warn(), .error(), .debug()
var serverId;

// Called when game server is initialized, passed server's object of current session
function init(rtSession) {
session = rtSession;
logger = session.getLogger();
logger.info(`[init] version=${version}`);
}

function dump(obj, depth) {
return util.inspect(obj, {
showHidden: true,
depth: depth || 0
});
}

// On Process Started is called when the process has begun and we need to perform any
// bootstrapping. This is where the developer should insert any code to prepare
// the process to be able to host a game session, for example load some settings or set state
//
// Return true if the process has been appropriately prepared and it is okay to invoke the
// GameLift ProcessReady() call.
function onProcessStarted(args) {
logger.info(`[onProcessStarted] ${dump(args)}`);
return true;
}

// Called when a new game session is started on the process
function onStartGameSession(gameSession) {
serverId = session.getServerId();
logger.info(`[onStartGameSession] serverId=${serverId} ${dump(gameSession)}`);
}

2.2. 게임룸 가입 및 검색

Player Session이 추가될 때 클라이언트와 게임 서버의 통신 플로우

2.2.1. 구현

LobbyManager.cs 파일을 열어 클라이언트에서 GameLift로 CreatePlayerSession을 비동기 적으로 호출하는 코드와 생성 된 세션을 검색하는 코드를 추가합니다.

  • 추가된 부분: 9, 55–77 행
namespace Lobby
{
using Amazon.GameLift;
using Amazon.GameLift.Model;
using Gameplay;
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections.Generic;
using System.Threading.Tasks;

[RequireComponent(typeof(AwsConfig))]
public sealed class LobbyManager : MonoBehaviour
{
AwsConfig m_Config;

void Awake()
{
m_Config = GetComponent<AwsConfig>();
}

AmazonGameLiftClient m_GameLift;

void OnEnable()
{
if (!m_Config.IsValid())
{
enabled = false;
return;
}

m_GameLift = new AmazonGameLiftClient(m_Config.AccessKeyId,
m_Config.SecretAccessKey, m_Config.Region.ToEndpoint());
}

void OnDisable()
{
m_GameLift?.Dispose();
}

public async Task<PlayerSession> CreateRoom(string roomName = null)
{
Debug.Log("CreateRoom");
if (string.IsNullOrEmpty(roomName)) roomName = Guid.NewGuid().ToString();
var request = new CreateGameSessionRequest
{
AliasId = m_Config.GameLiftAliasId,
MaximumPlayerSessionCount = 2,
Name = roomName
};
var response = await m_GameLift.CreateGameSessionAsync(request);
return await JoinRoom(response.GameSession.GameSessionId);
}

public async Task<PlayerSession> JoinRoom(string gameSessionId)
{
Debug.Log("JoinRoom");
var response = await m_GameLift.CreatePlayerSessionAsync(new CreatePlayerSessionRequest
{
GameSessionId = gameSessionId,
PlayerId = SystemInfo.deviceUniqueIdentifier,
});
var playerSession = response.PlayerSession;
NetworkManager.PlayerSession = playerSession;
SceneManager.LoadScene("Gameplay");
return playerSession;
}

public async Task<List<GameSession>> SearchRooms()
{
Debug.Log("SearchRooms");
var response = await m_GameLift.SearchGameSessionsAsync(new SearchGameSessionsRequest
{
AliasId = m_Config.GameLiftAliasId,
});
return response.GameSessions;
}

public void LocalPlay()
{
NetworkManager.PlayerSession = null;
SceneManager.LoadScene("Gameplay");
}
}
}

2.2.2. 버튼 액션 활성화

아래의 Project에서 GameSessionPanel.cs을 검색하여 파일을 열어줍니다.

컴파일 에러를 없애기 위해 CreateRoom이나, Joinroom의 function을 호출하는 부분에 임시로 해 놓은 주석을 제거합니다.

GameSessionPanel.cs 파일 주석 제거 후 저장

  • 변경된 부분: 24 행

LobbyPanel.cs 파일 주석 제거 후 저장

  • 아래 네모칸 부분의 앞에 추가된 주석 제거 ( // or /* */)

2.2.3. 서버 측

server.js 파일에 코드를 추가합니다.

  • 추가된 부분: 8, 50–51, 56–57 행
'use strict';
const version = '0.1.0';
const util = require('util');

var session; // The Realtime server session object
var logger; // Log at appropriate level via .info(), .warn(), .error(), .debug()
var serverId;
var activePlayers = 0; // Records the number of connected players

// Called when game server is initialized, passed server's object of current session
function init(rtSession) {
session = rtSession;
logger = session.getLogger();
logger.info(`[init] version=${version}`);
}

function dump(obj, depth) {
return util.inspect(obj, {
showHidden: true,
depth: depth || 0
});
}

// On Process Started is called when the process has begun and we need to perform any
// bootstrapping. This is where the developer should insert any code to prepare
// the process to be able to host a game session, for example load some settings or set state
//
// Return true if the process has been appropriately prepared and it is okay to invoke the
// GameLift ProcessReady() call.
function onProcessStarted(args) {
logger.info(`[onProcessStarted] ${dump(args)}`);
return true;
}

// Called when a new game session is started on the process
function onStartGameSession(gameSession) {
serverId = session.getServerId();
logger.info(`[onStartGameSession] serverId=${serverId} ${dump(gameSession)}`);

}
// Handle process termination if the process is being terminated by GameLift
// You do not need to call ProcessEnding here
function onProcessTerminate() {
// Perform any clean up
}

// On Player Connect is called when a player has passed initial validation
// Return true if player should connect, false to reject
function onPlayerConnect(connectMsg) {
logger.info(`[onPlayerConnect] ${dump(connectMsg)}`);
return true;
}

// Called when a Player is accepted into the game
function onPlayerAccepted(player) {
logger.info(`[onPlayerAccepted] ${dump(player)}`);
activePlayers++;
}

2.3. 게임 종료

게임이 종료 시 GameLift가 상태를 인식할 수 있도록 게임 서버가 GameLift 서비스에 게임이 종료 됐다는 것을 알려야 합니다.

GameLift Realtime Server를 이용한 경우, 서버측에서 ProcessEnding()을 호출합니다.

server.js 파일에 코드를 추가합니다.

  • 추가된 부분: 4, 10–12, 28–41, 58, 81–86 행
'use strict';
const version = '0.1.0';
const util = require('util');
const exitDelay = 20000;

var session; // The Realtime server session object
var logger; // Log at appropriate level via .info(), .warn(), .error(), .debug()
var serverId;
var activePlayers = 0; // Records the number of connected players
var exitTimeoutId;
var exiting = false;
var started = false;

// Called when game server is initialized, passed server's object of current session
function init(rtSession) {
session = rtSession;
logger = session.getLogger();
logger.info(`[init] version=${version}`);
}

function dump(obj, depth) {
return util.inspect(obj, {
showHidden: true,
depth: depth || 0
});
}

function tryDelayExit() {
clearTimeout(exitTimeoutId);
exitTimeoutId = setTimeout(function () {
if (activePlayers === 0) exit();
}, exitDelay);
}

async function exit() {
if (exiting) return;
exiting = true;
logger.info('[exit]');
await session.processEnding();
process.exit(0);
}

// On Process Started is called when the process has begun and we need to perform any
// bootstrapping. This is where the developer should insert any code to prepare
// the process to be able to host a game session, for example load some settings or set state
//
// Return true if the process has been appropriately prepared and it is okay to invoke the
// GameLift ProcessReady() call.
function onProcessStarted(args) {
logger.info(`[onProcessStarted] ${dump(args)}`);
return true;
}

// Called when a new game session is started on the process
function onStartGameSession(gameSession) {
serverId = session.getServerId();
logger.info(`[onStartGameSession] serverId=${serverId} ${dump(gameSession)}`);
tryDelayExit();
}
// Handle process termination if the process is being terminated by GameLift
// You do not need to call ProcessEnding here
function onProcessTerminate() {
// Perform any clean up
}

// On Player Connect is called when a player has passed initial validation
// Return true if player should connect, false to reject
function onPlayerConnect(connectMsg) {
logger.info(`[onPlayerConnect] ${dump(connectMsg)}`);
return true;
}

// Called when a Player is accepted into the game
function onPlayerAccepted(player) {
logger.info(`[onPlayerAccepted] ${dump(player)}`);
activePlayers++;
}
// On Player Disconnect is called when a player has left or been forcibly terminated
// Is only called for players that actually connected to the server and not those rejected by validation
// This is called before the player is removed from the player list
function onPlayerDisconnect(peerId) {
logger.info(`[onPlayerDisconnect] ${dump(peerId)}`);
activePlayers--;
if (activePlayers === 0) started = false;
tryDelayExit();
}

// Return true if the player is allowed to join the group
function onPlayerJoinGroup(groupId, peerId) {
return true;
}

// Return true if the player is allowed to leave the group
function onPlayerLeaveGroup(groupId, peerId) {
return true;
}

// Return true if the send should be allowed
function onSendToPlayer(gameMessage) {
return true;
}

// Return true if the send to group should be allowed
// Use gameMessage.getPayloadAsText() to get the message contents
function onSendToGroup(gameMessage) {
return true;
}

// Handle a message to the server
function onMessage(gameMessage) {
}

// Return true if the process is healthy
function onHealthCheck() {
return true;
}

exports.ssExports = {
init: init,
onProcessStarted: onProcessStarted,
onStartGameSession: onStartGameSession,
onProcessTerminate: onProcessTerminate,
onPlayerConnect: onPlayerConnect,
onPlayerAccepted: onPlayerAccepted,
onPlayerDisconnect: onPlayerDisconnect,
onPlayerJoinGroup: onPlayerJoinGroup,
onPlayerLeaveGroup: onPlayerLeaveGroup,
onSendToPlayer: onSendToPlayer,
onSendToGroup: onSendToGroup,
onMessage: onMessage,
onHealthCheck: onHealthCheck
};

2.4. 로비 테스트

게임룸 생성, 가입 및 검색, 게임 종료까지 구현하였습니다.

이제 구현한 기능에 대해서 테스트를 진행하겠습니다.

2.4.1. 스크립트 배포

server.js를 zip으로 압축합니다.

폴더가 아닌 server.js 파일을 압축합니다.

  • MAC의 경우: 마우스 우클릭 후 “server.js 압축’을 선택합니다. 또는 CLI로도 압축이 가능합니다.
    zip server.js.zip server.js
  • Windows10의 경우: server.js 파일 우클릭 후 보내기 → 압축(zip 형식) 폴더 선택
  • Windows11의 경우: server.js 파일 우클릭 후 zip파일로 압축

AWS Management Console에 로그인한 후 서비스 검색 필드에 GameLift를 입력한 다음 GameLift를 클릭합니다.

좌측 탐색 창에서 스크립트 선택. 이전에 생성했던 스크립트를 클릭한 다음 우측 상단의 편집을 선택합니다.

스크립트 설정 후 변경 내용을 저장합니다.

  • 이름: Puzzle
  • 버전: 0.1.1
  • 기존 스크립트 사용: 비활성화
  • 게임 서버 스크립트: zip 파일 업로드 선택 후 파일 선택 → 2.1~2.3 단계에서 생성한 zip 파일 선택

변경 내용 저장 버튼을 클릭합니다.

스크립트를 변경하면 자동으로 Fleet Instance에 배포됩니다.

Amazon GameLift는 업데이트 된 스크립트 리소스를 사용하는 모든 플릿 인스턴스에 스크립트 콘텐츠를 배포합니다. 업데이트 된 스크립트가 배포되면 인스턴스는 새 게임 세션을 시작할 때 이를 사용합니다. 업데이트 시점에 이미 실행 중인 게임 세션은 업데이트 된 스크립트를 사용하지 않습니다.

참고: https://docs.aws.amazon.com/gamelift/latest/developerguide/realtime-script-uploading.html

2.4.1.1. 스크립트 배포 확인

스크립트 업데이트 여부는 게임 세션 로그를 통해서 확인할 수 있습니다.

UnityEditor에서 플레이 버튼을 누릅니다.

CreateGame 버튼을 누르면 게임 세션이 만들어집니다.

GameLift 플릿의 게임 세션 항목에 들어가면 생성되었던 게임 세션의 리스트가 나옵니다.

가장 상단에 위치한 것이 가장 최신의 세션입니다.

가장 최신 세션에 들어가면 로그 다운로드 버튼이 있습니다.

로그를 다운로드하여 스크립트 버전 등 정보를 확인할 수 있습니다.

2.4.2. 로비 기능 테스트

Unity 프로젝트로 이동하여 빌드를 진행합니다.

빌드 파일이 저장될 폴더를 선택하고 빌드 파일 이름을 지정해줍니다.

쓰기 권한이 없는 폴더를 선택하면 아래와 같은 오류가 표시됩니다.

UnityException: Build path contains project built with “Create Visual Studio Solution” option

애플리케이션이 전체 화면으로 실행되는 경우, Unity의 Edit > Project Settings > Player > Resolution and Presentation > Fullscreen Mode를 Windowed로 설정한 후 Build And Run을 실행합니다.

빌드 된 파일이 실행되면 원하는 이름을 입력하고 Create를 클릭합니다.

방 이름을 입력한 후에 Create 버튼을 클릭합니다.

Unity에서 게임을 실행하여 위에서 생성한 세션을 확인할 수 있습니다.

Game 패널 선택 후 실행(►) 버튼 클릭

세션에 접속해도 아직 게임 플레이가 되지 않습니다.

해당 기능은 다음 단계에서 구현 해 보겠습니다.

3. 게임 본체를 멀티 플레이로 개조

이번 단계를 통해 싱글 플레이 게임을 다른 사람과 함께 할 수 있도록 멀티 플레이 게임으로 만들어 봅시다.

3.1. 통신 모듈 초기화

3.1.1. Network Manager 클래스 개요

대전 할 수 있도록 변경하려면 플레이어 양쪽의 정보를 공유해야 합니다. 통신 자체의 핸들링은 Gamelift Realtime Client측에서 실시하지만, 통신 내용이나 통신의 타이밍에 대해서는 Developer 측에서 결정해야 합니다.

이번에는 NetworkManager.cs을 이용하여 전체 통신을 처리합니다.

NetworkManager.cs 파일에 코드를 추가합니다.

  • 추가된 부분: 3–4, 17, 26 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}
}
}
}

3.1.2. Game Realtime Client Callback

OnOpen(), OnClose(), OnError(): 연결 시작, 종료 및 오류가 발생할 때의 콜백입니다.

OnDataReceived(): 게임 클라이언트가 RealTime 서버에서 메시지를 받으면 호출됩니다. 이것은 메시지와 알림이 게임 클라이언트에 의해 수신되는 주요 방법입니다.

OnGroupMembershipUpdated(): 플레이어가 속한 그룹의 멤버십이 업데이트될 때 호출됩니다. 이번 핸즈온에서는 사용하지 않습니다.

이제 각 Callback function을 정의하고 추가합니다. NetworkManager.cs 파일에 아래 코드를 추가하고, 파일을 저장합니다.

  • 추가된 부분: 5–6, 29–32, 45–73 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
}

}
}

3.1.3. Gamelift Realtime Client 초기화

다음은 PlayerSession 정보를 사용하여 Realtime Client를 초기화합니다.

NetworkManager.cs에 코드를 추가하고, 파일을 저장합니다.

  • 추가된 부분: 16, 45–59 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;
public ushort DefaultUdpPort = 7777;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}

int udpPort = NetworkUtility.SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
if (udpPort <= 0)
{
Debug.LogError("No available UDP port");
return;
}

m_Client.Connect(PlayerSession.IpAddress, PlayerSession.Port, udpPort,
new ConnectionToken(PlayerSession.PlayerSessionId, null));
}

void OnDisable()
{
if (m_Client.Connected) m_Client.Disconnect();
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
}

}
}

3.2. 게임 준비 확인

3.2.1. 클라이언트와 서버 간의 통신

연결이 설정되면 클라이언트는 GameLift Realtime Server와만 통신합니다. 통신은 Protobuf를 이용하여 RTMessage에서 실시합니다.

RTMessage 구조 (Protobuf를 사용하여 전송)

  • opcode(필수): 개발자 정의 작업 코드
  • payload : 메시지 내용
  • targetGroup: 타겟 그룹
  • targetPlayer: 전송 대상
  • deliveryIntent: 전송 유형(TCP/UDP)

3.2.2. GameClient Ready 구현

이제 게임 준비/시작에 대해 구현해 보도록 하겠습니다. 2명의 플레이어가 모이면 게임이 시작됩니다.

구현할 내용

  1. 클라이언트에서 OK 보내기
  2. 서버에서 게임 시작을 클라이언트로 보내기
  3. 클라이언트에서 수신하고 관련 동작 수행

클라이언트 준비

  1. 클라이언트에서 확인을 서버로 보내기
  2. 서버에서 모두 확인을 받으면 게임 시작

NetworkManager.cs를 열고 코드를 추가합니다.

  • 추가된 부분: 89–94, 97–101, 104–111 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;
public ushort DefaultUdpPort = 7777;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}

int udpPort = NetworkUtility.SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
if (udpPort <= 0)
{
Debug.LogError("No available UDP port");
return;
}

m_Client.Connect(PlayerSession.IpAddress, PlayerSession.Port, udpPort,
new ConnectionToken(PlayerSession.PlayerSessionId, null));
}

void OnDisable()
{
if (m_Client.Connected) m_Client.Disconnect();
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
switch (e.OpCode)
{
case OpCode.ServerStartGame:
if (m_Game) m_Game.enabled = true;
break;
}
}

public void SendReady()
{
if (!m_Connected) return;
m_Client.SendEvent(OpCode.ClientReadyToStart);
}
}

public static class OpCode
{
// 1xx Server to Client
public const int ServerStartGame = 101;

// 2xx Client to Server
public const int ClientReadyToStart = 201;
}
}

서버 측 준비 확인 로직

서버 측에는 다음 로직을 추가합니다.

onMessage에 데이터 수신 로직을 추가합니다. 클라이언트 준비 확인을 위해 setClientReady함수를 작성하고, 플레이어가 준비되고, 연결이 해제(disconnect)될 때 해당 함수를 호출합니다.

server.js 파일을 열고 코드를 추가합니다.

  • 추가된 부분: 6–7, 16, 114–143 행
'use strict';
const version = '0.1.0';
const util = require('util');
const exitDelay = 20000;

const OpServerStartGame = 101;
const OpClientReadyToStart = 201;

var session; // The Realtime server session object
var logger; // Log at appropriate level via .info(), .warn(), .error(), .debug()
var serverId;
var activePlayers = 0; // Records the number of connected players
var exitTimeoutId;
var exiting = false;
var started = false;
var ready = {};

// Called when game server is initialized, passed server's object of current session
function init(rtSession) {
session = rtSession;
logger = session.getLogger();
logger.info(`[init] version=${version}`);
}

function dump(obj, depth) {
return util.inspect(obj, {
showHidden: true,
depth: depth || 0
});
}

function tryDelayExit() {
clearTimeout(exitTimeoutId);
exitTimeoutId = setTimeout(function () {
if (activePlayers === 0) exit();
}, exitDelay);
}

async function exit() {
if (exiting) return;
exiting = true;
logger.info('[exit]');
await session.processEnding();
process.exit(0);
}

// On Process Started is called when the process has begun and we need to perform any
// bootstrapping. This is where the developer should insert any code to prepare
// the process to be able to host a game session, for example load some settings or set state
//
// Return true if the process has been appropriately prepared and it is okay to invoke the
// GameLift ProcessReady() call.
function onProcessStarted(args) {
logger.info(`[onProcessStarted] ${dump(args)}`);
return true;
}

// Called when a new game session is started on the process
function onStartGameSession(gameSession) {
serverId = session.getServerId();
logger.info(`[onStartGameSession] serverId=${serverId} ${dump(gameSession)}`);
tryDelayExit();
}
// Handle process termination if the process is being terminated by GameLift
// You do not need to call ProcessEnding here
function onProcessTerminate() {
// Perform any clean up
}

// On Player Connect is called when a player has passed initial validation
// Return true if player should connect, false to reject
function onPlayerConnect(connectMsg) {
logger.info(`[onPlayerConnect] ${dump(connectMsg)}`);
return true;
}

// Called when a Player is accepted into the game
function onPlayerAccepted(player) {
logger.info(`[onPlayerAccepted] ${dump(player)}`);
activePlayers++;
}
// On Player Disconnect is called when a player has left or been forcibly terminated
// Is only called for players that actually connected to the server and not those rejected by validation
// This is called before the player is removed from the player list
function onPlayerDisconnect(peerId) {
logger.info(`[onPlayerDisconnect] ${dump(peerId)}`);
activePlayers--;
if (activePlayers === 0) started = false;
setClientReady(peerId, false);
tryDelayExit();
}

// Return true if the player is allowed to join the group
function onPlayerJoinGroup(groupId, peerId) {
return true;
}

// Return true if the player is allowed to leave the group
function onPlayerLeaveGroup(groupId, peerId) {
return true;
}

// Return true if the send should be allowed
function onSendToPlayer(gameMessage) {
return true;
}

// Return true if the send to group should be allowed
// Use gameMessage.getPayloadAsText() to get the message contents
function onSendToGroup(gameMessage) {
return true;
}

// Handle a message to the server
function onMessage(gameMessage) {
logger.info(`[onMessage] ${dump(gameMessage)}`);
switch (gameMessage.opCode) {
case OpClientReadyToStart:
setClientReady(gameMessage.sender, true);
break;
}
}

function setClientReady(peerId, value) {
if (value) {
ready[peerId] = true;
logger.info(`[setClientReady] peerId=${peerId} count=${Object.keys(ready).length}`);
if (started) {
session.sendReliableMessage(newMessage(OpServerStartGame), peerId);
} else if (Object.keys(ready).length >= 2) {
started = true;
session.sendReliableGroupMessage(newMessage(OpServerStartGame), -1);
}
} else {
delete ready[peerId];
}
}

function newMessage(opCode, data) {
var message = session.newBinaryGameMessage(opCode, serverId, data || new Uint8Array(0));
logger.info(`[newMessage] ${dump(message)}`);
return message;
}

// Return true if the process is healthy
function onHealthCheck() {
return true;
}

exports.ssExports = {
init: init,
onProcessStarted: onProcessStarted,
onStartGameSession: onStartGameSession,
onProcessTerminate: onProcessTerminate,
onPlayerConnect: onPlayerConnect,
onPlayerAccepted: onPlayerAccepted,
onPlayerDisconnect: onPlayerDisconnect,
onPlayerJoinGroup: onPlayerJoinGroup,
onPlayerLeaveGroup: onPlayerLeaveGroup,
onSendToPlayer: onSendToPlayer,
onSendToGroup: onSendToGroup,
onMessage: onMessage,
onHealthCheck: onHealthCheck
};

ready 버튼 사용

GamePanel.cs 파일을 열고 주석 처리된 부분을 제거합니다.

  • 25행의 주석 제거
namespace Gameplay
{
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public sealed class GamePanel : MonoBehaviour
{
public GameManager Game;
public NetworkManager Network;
public Text ModeText;
public Button ReadyButton;
public Button LobbyButton;

void Start()
{
ReadyButton.onClick.AddListener(Ready);
LobbyButton.onClick.AddListener(Lobby);
ModeText.text = NetworkManager.PlayerSession == null ? "Single" : "Multi";
}

void Ready()
{
if (NetworkManager.PlayerSession == null) Game.enabled = true;
else Network.SendReady();
ReadyButton.interactable = false;
}

void Lobby()
{
SceneManager.LoadScene("Lobby");
}
}
}

3.3. 점수 보드

클라이언트 간 데이터 공유를 위해서는 다음 단계가 필요합니다.

  1. 점수 보드 정보 직렬화
  2. 점수 보드 정보 업데이트 시 정보 전송
  3. 클라이언트가 수신되면 상대방 보드 정보 업데이트

NetworkManager.cs를 열고 코드를 추가합니다.

  • 추가된 부분: 107–112, 123–124 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;
public ushort DefaultUdpPort = 7777;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}

int udpPort = NetworkUtility.SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
if (udpPort <= 0)
{
Debug.LogError("No available UDP port");
return;
}

m_Client.Connect(PlayerSession.IpAddress, PlayerSession.Port, udpPort,
new ConnectionToken(PlayerSession.PlayerSessionId, null));
}

void OnDisable()
{
if (m_Client.Connected) m_Client.Disconnect();
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
switch (e.OpCode)
{
case OpCode.ServerStartGame:
if (m_Game) m_Game.enabled = true;
break;
case OpCode.ClientUpdateBlocks:
if (e.Sender != m_Client.Session.ConnectedPeerId)
Viewer.SetBlocks(e.Data, m_Color.ShapeColors);
break;
}
}

public void SendReady()
{
if (!m_Connected) return;
m_Client.SendEvent(OpCode.ClientReadyToStart);
}

public void SendUpdateBlocks(byte[] data)
{
if (!m_Connected) return;
var message = m_Client.NewMessage(OpCode.ClientUpdateBlocks).WithTargetGroup(Constants.GROUP_ID_ALL_PLAYERS).WithPayload(data);
m_Client.SendMessage(message);
}
}

public static class OpCode
{
// 1xx Server to Client
public const int ServerStartGame = 101;

// 2xx Client to Server
public const int ClientReadyToStart = 201;

// 3xx Client to Client
public const int ClientUpdateBlocks = 301;
}
}

NetworkManager.cs 파일의 수정이 완료되면, 마지막으로 실제로 송신하는 코드를 추가합니다.

GameManager.cs 파일을 열고 코드를 추가합니다.

  • UpdateViewer() 함수에 6행 if문 추가
void UpdateViewer()
{
if (!m_ViewerDirty) return;
m_Grid.Capture(m_ViewerBuffer);
Viewer.SetBlocks(m_ViewerBuffer, m_Color.ShapeColors);
if (m_Network) m_Network.SendUpdateBlocks(m_ViewerBuffer);
m_ViewerDirty = false;
}

서버 측에서 onSendToGroup Callback에서 로직을 정의할 수 있지만, 이번 핸즈온에서는 따로 추가하지 않습니다.

3.4. 점수 업데이트

3.4.1. 구현

점수를 추가합니다. 테트리스의 블록을 한 줄 이상 지우면 서버에 해당 정보를 보냅니다.

NetworkManager.cs 파일을 열고 코드를 추가합니다.

  • 추가된 부분: 98–104, 121–128, 141 행
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;
public ushort DefaultUdpPort = 7777;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}

int udpPort = NetworkUtility.SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
if (udpPort <= 0)
{
Debug.LogError("No available UDP port");
return;
}

m_Client.Connect(PlayerSession.IpAddress, PlayerSession.Port, udpPort,
new ConnectionToken(PlayerSession.PlayerSessionId, null));
}

void OnDisable()
{
if (m_Client.Connected) m_Client.Disconnect();
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
switch (e.OpCode)
{
case OpCode.ServerStartGame:
if (m_Game) m_Game.enabled = true;
break;
case OpCode.ClientUpdateBlocks:
if (e.Sender != m_Client.Session.ConnectedPeerId)
Viewer.SetBlocks(e.Data, m_Color.ShapeColors);
break;
case OpCode.ClientUpdateScore:
if (e.Sender != m_Client.Session.ConnectedPeerId)
{
e.Data.AsIndexedBytes(4).ReadInt32(out int line).ReadInt32(out int score);
Viewer.SetScore(score, line);
}
break;
}
}

public void SendReady()
{
if (!m_Connected) return;
m_Client.SendEvent(OpCode.ClientReadyToStart);
}

public void SendUpdateBlocks(byte[] data)
{
if (!m_Connected) return;
var message = m_Client.NewMessage(OpCode.ClientUpdateBlocks).WithTargetGroup(Constants.GROUP_ID_ALL_PLAYERS).WithPayload(data);
m_Client.SendMessage(message);
}

public void SendUpdateScore(int count, int line, int score)
{
if (!m_Connected) return;
var data = new byte[sizeof(int) * 3];
data.AsIndexedBytes().WriteInt32(count).WriteInt32(line).WriteInt32(score);
var message = m_Client.NewMessage(OpCode.ClientUpdateScore).WithTargetGroup(-1).WithPayload(data);
m_Client.SendMessage(message);
}
}

public static class OpCode
{
// 1xx Server to Client
public const int ServerStartGame = 101;

// 2xx Client to Server
public const int ClientReadyToStart = 201;

// 3xx Client to Client
public const int ClientUpdateBlocks = 301;
public const int ClientUpdateScore = 302;
}
}

이제 서버에 실제로 정보를 보내는 코드를 추가합니다.

GameManager.cs 파일을 열고 코드를 추가합니다.

  • 기존 122행에 정의되어있는 UpdateGrid() 함수에 아래 10행의 if문을 추가합니다.
void UpdateGrid()
{
if (!m_GridDirty) return;
int count = m_Grid.RemoveFullRows();
if (count > 0)
{
m_LineScore += count;
m_TotalScore += m_Score.Get(count);
Viewer.SetScore(m_TotalScore, m_LineScore);
if (m_Network) m_Network.SendUpdateScore(count, m_LineScore, m_TotalScore);
}
m_GridDirty = false;
}

3.5. 게임 테스트

게임 준비 확인, 점수 보드 공유, 점수 업데이트 구현은 모두 끝났습니다. 이제 실제로 게임을 실행하여 구현한 기능들이 정상적으로 작동하는지 테스트를 진행해 보도록 하겠습니다.

3.5.1. 스크립트 배포

3.2.2 단계에서 수정한 server.js 파일을 server.js.zip으로 압축합니다.

폴더가 아닌 server.js 파일을 압축합니다.

  • MAC의 경우: 마우스 우클릭 후 server.js 압축을 선택합니다. 또는 CLI로도 압축이 가능합니다.
zip server.js.zip server.js
  • Windows10의 경우: server.js 파일 우클릭 후 보내기 → 압축(zip 형식) 폴더 선택
  • Windows11의 경우: server.js 파일 우클릭 후 ZIP파일로 압축

AWS Management Console에 로그인한 후 서비스 검색 필드에 GameLift를 입력한 다음 GameLift를 클릭합니다.

좌측 탐색 창에서 스크립트 선택. 이전에 생성했던 스크립트를 클릭한 다음 우측 상단의 편집을 선택합니다.

스크립트 설정 후 변경 내용을 저장합니다.

  • 이름: Puzzle
  • 버전: 0.1.2
  • 기존 스크립트 사용: 비활성화
  • 게임 서버 스크립트: zip 파일 업로드 선택 후 파일 선택 → 이전 단계에서 생성한 zip 파일 선택

변경 내용 저장 버튼을 클릭합니다.

3.5.2. 로비 기능 테스트

Unity에서 File → Build And Run 을 클릭하여 게임을 실행합니다.

방 이름을 입력하고 Create버튼을 클릭하여 방을 생성합니다.

Unity 에디터에서 실행 버튼(▶︎)을 클릭하여 게임을 실행해보면 위에서 생성한 방을 확인할 수 있습니다.

방이 보이지 않는다면, Search버튼을 클릭합니다.

Join버튼을 클릭하여 게임에 입장합니다.

Ready버튼을 클릭하여 게임을 시작할 수 있습니다.

게임을 플레이해보면 블록을 제거할 때마다 score가 올라가는 것을 확인할 수 있습니다.

3.6. (챌린지) 방해 블록 던지기

테트리스 블록을 한 줄 씩 지울 때 마다 상대방에게 블록을 던집니다.

방해 블록을 던지는 로직은 이미 구현되어있는 상태입니다.

  1. OpCode를 추가합니다.
  2. 삭제 된 정보를 전송합니다. (UpdateScore를 사용해도 괜찮습니다.)
  3. 서버 측 onSendToGroup 함수에서 사용자 ID를 결정하고 특정 사용자에게 방해 차단 전송할 로직을 추가합니다.
  4. 클라이언트 측에서 수신하여 블록을 업데이트합니다.

NetworkManager.cs 파일을 열고, 코드를 추가합니다.

  • 추가된 부분: 94–97, 139
namespace Gameplay
{
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using System;
using UnityEngine;
using System.Threading;

[RequireComponent(typeof(ColorConfig))]
public sealed class NetworkManager : MonoBehaviour
{
public static PlayerSession PlayerSession;

public Viewer Viewer;
public ushort DefaultUdpPort = 7777;

GameManager m_Game;
ColorConfig m_Color;
Client m_Client;
bool m_Connected;
SynchronizationContext context;

void Awake()
{
context = SynchronizationContext.Current;
m_Game = GetComponent<GameManager>();
m_Color = GetComponent<ColorConfig>();
m_Client = new Client();
m_Client.ConnectionOpen += OnOpen;
m_Client.ConnectionClose += OnClose;
m_Client.ConnectionError += OnError;
m_Client.DataReceived += OnDataThread;
}

void OnEnable()
{
if (PlayerSession == null)
{
enabled = false;
Debug.Log("NetworkManager.PlayerSession not set");
return;
}

int udpPort = NetworkUtility.SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
if (udpPort <= 0)
{
Debug.LogError("No available UDP port");
return;
}

m_Client.Connect(PlayerSession.IpAddress, PlayerSession.Port, udpPort,
new ConnectionToken(PlayerSession.PlayerSessionId, null));
}

void OnDisable()
{
if (m_Client.Connected) m_Client.Disconnect();
}

void OnOpen(object sender, EventArgs e)
{
Debug.Log("OnOpen");
m_Connected = true;
}

void OnClose(object sender, EventArgs e)
{
Debug.Log("OnClose");
m_Connected = false;
}

void OnError(object sender, ErrorEventArgs e)
{
Debug.LogError("OnError");
Debug.LogException(e.Exception);
}

void OnDataThread(object sender, DataReceivedEventArgs e)
{
context.Post(_ =>
{
OnData(sender, e);
}, null);
}

void OnData(object sender, DataReceivedEventArgs e)
{
switch (e.OpCode)
{
case OpCode.ServerStartGame:
if (m_Game) m_Game.enabled = true;
break;
case OpCode.ServerInsertObstacle:
e.Data.AsIndexedBytes().ReadInt32(out int count);
m_Game.InsertObstacleRows(count);
break;
case OpCode.ClientUpdateBlocks:
if (e.Sender != m_Client.Session.ConnectedPeerId)
Viewer.SetBlocks(e.Data, m_Color.ShapeColors);
break;
case OpCode.ClientUpdateScore:
if (e.Sender != m_Client.Session.ConnectedPeerId)
{
e.Data.AsIndexedBytes(4).ReadInt32(out int line).ReadInt32(out int score);
Viewer.SetScore(score, line);
}
break;
}
}

public void SendReady()
{
if (!m_Connected) return;
m_Client.SendEvent(OpCode.ClientReadyToStart);
}

public void SendUpdateBlocks(byte[] data)
{
if (!m_Connected) return;
var message = m_Client.NewMessage(OpCode.ClientUpdateBlocks).WithTargetGroup(Constants.GROUP_ID_ALL_PLAYERS).WithPayload(data);
m_Client.SendMessage(message);
}

public void SendUpdateScore(int count, int line, int score)
{
if (!m_Connected) return;
var data = new byte[sizeof(int) * 3];
data.AsIndexedBytes().WriteInt32(count).WriteInt32(line).WriteInt32(score);
var message = m_Client.NewMessage(OpCode.ClientUpdateScore).WithTargetGroup(-1).WithPayload(data);
m_Client.SendMessage(message);
}
}

public static class OpCode
{
// 1xx Server to Client
public const int ServerStartGame = 101;
public const int ServerInsertObstacle = 102;

// 2xx Client to Server
public const int ClientReadyToStart = 201;

// 3xx Client to Client
public const int ClientUpdateBlocks = 301;
public const int ClientUpdateScore = 302;
}
}

server.js 파일을 열어 코드를 추가합니다.

  • 추가된 부분: 6, 8, 97–108, 169–173
'use strict';
const version = '0.1.10';
const util = require('util');

const OpServerStartGame = 101;
const OpServerInsertObstacle = 102;
const OpClientReadyToStart = 201;
const OpClientUpdateScore = 302;

const exitDelay = 20000;

var session; // The Realtime server session object
var logger; // Log at appropriate level via .info(), .warn(), .error(), .debug()
var serverId;
var activePlayers = 0; // Records the number of connected players
var exitTimeoutId;
var exiting = false;
var ready = {};
var started = false;

// Called when game server is initialized, passed server's object of current session
function init(rtSession) {
session = rtSession;
logger = session.getLogger();
logger.info(`[init] version=${version}`);
}

// On Process Started is called when the process has begun and we need to perform any
// bootstrapping. This is where the developer should insert any code to prepare
// the process to be able to host a game session, for example load some settings or set state
//
// Return true if the process has been appropriately prepared and it is okay to invoke the
// GameLift ProcessReady() call.
function onProcessStarted(args) {
logger.info(`[onProcessStarted] ${dump(args)}`);
return true;
}

// Called when a new game session is started on the process
function onStartGameSession(gameSession) {
serverId = session.getServerId();
logger.info(`[onStartGameSession] serverId=${serverId} ${dump(gameSession)}`);
tryDelayExit();
}

// Handle process termination if the process is being terminated by GameLift
// You do not need to call ProcessEnding here
function onProcessTerminate() {
logger.info(`[onProcessTerminate]`);
}

// On Player Connect is called when a player has passed initial validation
// Return true if player should connect, false to reject
function onPlayerConnect(connectMsg) {
logger.info(`[onPlayerConnect] ${dump(connectMsg)}`);
return true;
}

// Called when a Player is accepted into the game
function onPlayerAccepted(player) {
logger.info(`[onPlayerAccepted] ${dump(player)}`);
activePlayers++;
}

// On Player Disconnect is called when a player has left or been forcibly terminated
// Is only called for players that actually connected to the server and not those rejected by validation
// This is called before the player is removed from the player list
function onPlayerDisconnect(peerId) {
logger.info(`[onPlayerDisconnect] ${dump(peerId)}`);
activePlayers--;
if (activePlayers === 0) started = false;
setClientReady(peerId, false);
tryDelayExit();
}

// Return true if the player is allowed to join the group
function onPlayerJoinGroup(groupId, peerId) {
logger.info(`[onPlayerJoinGroup] ${dump(groupId)} ${dump(peerId)}`);
return true;
}

// Return true if the player is allowed to leave the group
function onPlayerLeaveGroup(groupId, peerId) {
logger.info(`[onPlayerLeaveGroup] ${dump(groupId)} ${dump(peerId)}`);
return true;
}

// Return true if the send should be allowed
function onSendToPlayer(gameMessage) {
logger.info(`[onSendToPlayer] ${dump(gameMessage)}`);
return true;
}

// Return true if the send to group should be allowed
// Use gameMessage.getPayloadAsText() to get the message contents
function onSendToGroup(gameMessage) {
logger.info(`[onSendToGroup] ${dump(gameMessage)}`);
switch (gameMessage.opCode) {
case OpClientUpdateScore:
var buf = new Buffer(gameMessage.payload);
var count = buf.readInt32BE(0);
if (count > 0) {
var targetId = Number(Object.keys(ready).find(id => Number(id) !== gameMessage.sender));
if (targetId > 0) insertObstacle(targetId, count);
}
break;
}
return true;
}

// Handle a message to the server
function onMessage(gameMessage) {
logger.info(`[onMessage] ${dump(gameMessage)}`);
switch (gameMessage.opCode) {
case OpClientReadyToStart:
setClientReady(gameMessage.sender, true);
break;
}
}

// Return true if the process is healthy
function onHealthCheck() {
return true;
}

function dump(obj, depth) {
return util.inspect(obj, {
showHidden: true,
depth: depth || 0
});
}

function tryDelayExit() {
clearTimeout(exitTimeoutId);
exitTimeoutId = setTimeout(function () {
if (activePlayers === 0) exit();
}, exitDelay);
}

async function exit() {
if (exiting) return;
exiting = true;
logger.info('[exit]');
await session.processEnding();
process.exit(0);
}

function setClientReady(peerId, value) {
if (value) {
ready[peerId] = true;
logger.info(`[setClientReady] peerId=${peerId} count=${Object.keys(ready).length}`);
if (started) {
session.sendReliableMessage(newMessage(OpServerStartGame), peerId);
} else if (Object.keys(ready).length >= 2) {
started = true;
session.sendReliableGroupMessage(newMessage(OpServerStartGame), -1);
}
} else {
delete ready[peerId];
}
}

function newMessage(opCode, data) {
var message = session.newBinaryGameMessage(opCode, serverId, data || new Uint8Array(0));
logger.info(`[newMessage] ${dump(message)}`);
return message;
}

function insertObstacle(peerId, count) {
var buf = Buffer.allocUnsafe(4);
buf.writeInt32BE(count, 0);
session.sendReliableMessage(newMessage(OpServerInsertObstacle, buf), peerId);
}

exports.ssExports = {
init: init,
onProcessStarted: onProcessStarted,
onStartGameSession: onStartGameSession,
onProcessTerminate: onProcessTerminate,
onPlayerConnect: onPlayerConnect,
onPlayerAccepted: onPlayerAccepted,
onPlayerDisconnect: onPlayerDisconnect,
onPlayerJoinGroup: onPlayerJoinGroup,
onPlayerLeaveGroup: onPlayerLeaveGroup,
onSendToPlayer: onSendToPlayer,
onSendToGroup: onSendToGroup,
onMessage: onMessage,
onHealthCheck: onHealthCheck
};

3.5.1. 스크립트 배포 단계를 동일하게 진행하여 위에서 수정한 server.js 파일을 배포합니다.

배포 후에 게임을 진행하면 아래와 같이 블록을 제거할 때마다 상대방에게 블록이 추가되는 것을 볼 수 있습니다.

--

--