[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(78일차) - 모바일 틱택토 게임 구현 (6), ParrelSync를 활용한 멀티플레이 확인
by 독기품은토끼2025. 9. 9.
✅ 오늘의 학습 목표 1. 모바일 틱택토 게임 마무리 - 소켓 연결 및 이벤트 콜백 - 멀티 플레이 게임 로직 구현 - 로컬 클릭과 네트워크 클릭 구분 - ParrelSync를 활용한 멀티플레이 확인
1. 소켓 연결 및 이벤트 콜백 (클라이언트)
어제는 WebSocket 연결과 서버 이벤트명에 대응하는 콜백 등록까지 끝냈고
오늘은 각 콜백이 받은 데이터(페이로드)를 GameLogic/UI 갱신으로 이어지게 만들어 줄 것이다.
using System;
using System.Net.Sockets;
using Newtonsoft.Json;
using SocketIOClient;
using UnityEngine;
// joinRoom/createRoom 이벤트 전달할 때 전달되는 정보의 타입
public class RoomData
{
[JsonProperty("roomId")]
public string roomId { get; set; }
}
// 상대방이 둔 마커 위치
public class BlockData
{
[JsonProperty("blockIndex")]
public int blockIndex { get; set; }
}
public class MultiplayController : IDisposable
{
private SocketIOUnity _socket;
private Action<Constants.MultiplayControllerState, string> _onMultiplayStateChanged; // Room 상태 변화에 따른 동작을 할당하는 변수
public Action<int> onBlockDataChanged; // 게임 진행 상황에서 Marker의 위치를 업데이트 하는 변수
public MultiplayController(Action<Constants.MultiplayControllerState, string> onMultiplayStateChanged)
{
// 서버에서 이벤트가 발생하면 처리할 메서드를 _onMultiplayStateChanged에 등록
_onMultiplayStateChanged = onMultiplayStateChanged;
// Socket.io 클라이언트 초기화
var uri = new Uri(Constants.SocketServerURL);
_socket = new SocketIOUnity(uri, new SocketIOOptions
{
Transport = SocketIOClient.Transport.TransportProtocol.WebSocket,
Reconnection = false, // 자동 재접속 끄기
ReconnectionAttempts = 0 // 혹시 모를 시도 수 0
});
_socket.On("createRoom", CreateRoom);
_socket.On("joinRoom", JoinRoom);
_socket.On("startGame", StartGame);
_socket.On("exitRoom", ExitRoom);
_socket.On("endGame", EndGame);
_socket.On("doOpponent", DoOpponent);
_socket.Connect(); // 서버 접속
}
private void CreateRoom(SocketIOResponse response)
{
var data = response.GetValue<RoomData>();
UnityThread.executeInUpdate(() =>
{
_onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.CreateRoom,
data.roomId);
});
}
private void JoinRoom(SocketIOResponse response)
{
var data = response.GetValue<RoomData>();
UnityThread.executeInUpdate(() =>
{
_onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.JoinRoom,
data.roomId);
});
}
private void StartGame(SocketIOResponse response)
{
var data = response.GetValue<RoomData>();
UnityThread.executeInUpdate(() =>
{
_onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.StartGame,
data.roomId);
});
}
private void ExitRoom(SocketIOResponse response)
{
UnityThread.executeInUpdate(() =>
{
_onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.ExitRoom, null);
});
}
private void EndGame(SocketIOResponse response)
{
UnityThread.executeInUpdate(() =>
{
_onMultiplayStateChanged?.Invoke(Constants.MultiplayControllerState.EndGame, null);
});
}
private void DoOpponent(SocketIOResponse response)
{
var data = response.GetValue<BlockData>();
UnityThread.executeInUpdate(() =>
{
onBlockDataChanged?.Invoke(data.blockIndex);
});
}
#region Client => Server
public void LeaveRoom(string roomId) // Room을 나올 때 호출하는 메서드
{
_socket.Emit("leaveRoom", new { roomId });
}
public void DoPlayer(string roomId, int blockIndex) // 플레이어가 Marker를 두면 호출하는 메서드
{
_socket.Emit("doPlayer", new { roomId, blockIndex });
}
#endregion
public void Dispose()
{
if (_socket != null)
{
_socket.Disconnect(); // 서버 연결 끊고
_socket.Dispose(); // 소켓 삭제
_socket = null;
}
}
}
[생성자 파라미터]
public MultiplayController(Action<Constants.MultiplayControllerState, string> onMultiplayStateChanged)
서버에서는 create, join, start, exit, end 등 여러 이벤트를 클라이언트에게 보낸다.
클라이언트는 각 이벤트 상태에 따라 다른 로직이 실행되도록 구현해야 하기 때문에 상태와 string 타입의 roomId를 파라미터로 갖는다.
상태만 가지고 와도 되지 않나? 싶지만 멀티플레이의 경우
roomId를 갖고와서 정해진 공간 내의 정해진 인원만 수용하도록 하기 위함이다.
[UnityThread]
Socket.IO 콜백은 별도 스레드/시점에서 호출될 수 있다.
그런데 Unity의 GameObject, UI, State 전환은 메인 스레드에서만 안전하다.
(오브젝트 생성/파괴, Transform 변경, UI 갱신 등 메인 루프(Update)에서만 보장됨 → 소켓 콜백에서 바로 UI를 건들면 무시되거나 로그만 찍혀버림)
그래서 메인 스레드가 작업할 수 있도록 마샬링 해주는 역할이 바로 UnityTread.executeInUpdate()이다.
해당 작업을 해주지 않으면 플레이어가 두는 수가 UI에 표시되지 않는다.
[IDisposable]
Socket같은 운영체제 자원은 GC가 언제 쓰레기값을 버려야 할지 난감해한다.
그래서 이 종료/삭제 타이밍을 주기위해 IDisposable의 Dispose() 메서드를 활용한다.
서버와의 연결 끊기(Disconnect)
소켓 객체 자체 해제(Dispose)
참조 제거(재진입 방지)
이 작업을 해주지 않으면 소켓 연결이 남아있어서 포트/스레드를 붙잡아 리소스 누수가 발생할 수 있고
이벤트 핸들러가 남아있어 중복 콜백, 유령 로그 같은 원치 않은 작업이 발생할 수 있다.
[소켓 옵션]
_socket = new SocketIOUnity(uri, new SocketIOOptions {
Transport = TransportProtocol.WebSocket,
Reconnection = false,
ReconnectionAttempts = 0
});
WebSocket 고정: 실시간 상호작용에 적합
자동 재접속 끔: 의도치 않은 재참여/이벤트 중복을 피하기 위함
[주의할 점]
public void DoPlayer(string roomId, int blockIndex) {
_socket.Emit("doPlayer", new { roomId, blockIndex });
}
서버는 blockIndex라는 이름을 키로 받고 있는데
맨 처음 구현할 때 posiotion이라는 이름으로 구현을 하여 원하는 로직이 제대로 실행되지 않았다.
함수명만 동일하면 될 줄 알았는데 파라미터명까지 동일해야 했던 것이다..
Socket.IO는 이벤트 이름과 JSON 키 이름으로 통신하기 때문에
이벤트 이름이 다르면 콜백 자체가 안 불리고
JSON키가 다르면 서버/클라에서 해당 필드가 undefined/default가 되어 동작이 실패한다.
[Emit과 new 키워드]
Emit은 params object[] 가변 인자를 갖는다.
Emit("event", a, b, c) 처럼 여러 인자를 보낼 수도 있고
emit("event", someObject)처럼 하나의 객체만 보낼 수도 있다.
socket.on('doPlayer', function(playerInfo) {
var roomId = playerInfo.roomId;
var blockIndex = playerInfo.blockIndex;
});
서버에서는 하나의 객체를 받고 그 안에서 roomId와 blockIndex라는 키 이름으로 값을 꺼낸다.
만약 클라이언트 쪽에서 Emit("doPlayer", roomId, blockIndex)로 보내면 서버는 이벤트 하나와 인자 2개를 받게 되어 충돌이 발생된다.
그렇다고 Emit("doPlayer", {roomId, blockIndex}) 이렇게 보내버리면 이건 그냥 C# 문법 에러이다.
배열 초기화 → 변수 선언과 함께 int[] a = { 1, 2 };처럼 new를 생략할 수 있음
인자 → Emit("e", new[] { 1, 2 })처럼 new[] { … }가 반드시 필요
객체/컬렉션 초기화 → (new List<int> { 1, 2 }, new Payload { Prop = … })는 늘 new가 붙음
2. 게임 로직 + 턴 관리
이제 멀티플레이의 게임 진행을 처리해줄 것이다.
누구의 턴인지를 관리하고 서버의 이벤트 (create, join, start .. 등)에 따라 어느 슬롯이 로컬/원격인지 배치해 줄 것이다.
using System;
using UnityEngine;
public class GameLogic : IDisposable
{
private MultiplayController _multiplayController; // 멀티플레이 기능
private string _roomId;
public GameLogic(BlockController blockController, Constants.GameType gameType)
{
// Game Type 초기화
switch (gameType)
{
case Constants.GameType.MultiPlay:
_multiplayController = new MultiplayController((state, roomId) =>
{
_roomId = roomId;
switch (state)
{
case Constants.MultiplayControllerState.CreateRoom:
Debug.Log("## Create Room ##");
// TODO : 대기 화면 UI 표시
break;
case Constants.MultiplayControllerState.JoinRoom:
Debug.Log("## Join Room ##");
firstPlayerState = new MultiPlayerState(true, _multiplayController);
secondPlayerState = new PlayerState(false, _multiplayController, _roomId);
SetState(firstPlayerState);
break;
case Constants.MultiplayControllerState.StartGame:
Debug.Log("## Start Game ##");
firstPlayerState = new PlayerState(true, _multiplayController, _roomId);
secondPlayerState = new MultiPlayerState(false, _multiplayController);
SetState(firstPlayerState);
break;
case Constants.MultiplayControllerState.ExitRoom:
Debug.Log("## Exit Room ##");
// TODO : 팝업 띄우고 Main 화면으로 이동
break;
case Constants.MultiplayControllerState.EndGame:
Debug.Log("## End Game ##");
// TODO : 팝업 띄우고 Main 화면으로 이동
break;
}
});
break;
}
}
public void Dispose()
{
_multiplayController?.LeaveRoom(_roomId);
_multiplayController?.Dispose();
}
}
멀티플레이일 때 서버 통역사(= MultiplayController)를 만들고
서버가 보내는 방 이벤트(create/join/start/exit/end)를 콜백으로 받아
현재 턴의 상태(로컬 입력을 받을지, 원격 입력을 받을지)를 SetState(...)로 교체해 가며 게임을 진행
씬을 나가거나 게임을 닫을 때 방 나가기 + 소켓 정리까지 책임짐
[joinRoom = 참가자 기준 = 상대가 먼저]
[내 화면 기준]
1P = 상대(원격) → MultiPlayerState(true)
2P = 나 (로컬) → PlayerState(false)
시작 상태 = firstPlayerState(=원격 차례)
참가자는 후입장이라 먼저 들어와 기다리던 상대(방장)가 선공한다.
따라서 첫 턴은 상대(원격)가 둔다.
[startGame = 방장 기준 = 내가 먼저]
[내 화면 기준]
1P = 나 (로컬) → PlayerState(true)
2P = 상대(원격) → MultiPlayerState(false)
시작 상태 = firstPlayerState(=내 차례)
방장은 선입장자이면서 선공이다.
첫 턴은 내가 클릭해야하니 로컬 입력 델리게이트가 연결되는 PlayerState(true) 값을 받고
원격(상대방)은 대기한다.
3. 플레이어 상태
public class PlayerState : BasePlayerState
{
private MultiplayController _multiplayController;
private string _roomId;
private bool _isMultiplay;
public PlayerState(bool isFirstPlayer) // 싱글 플레이용 생성자
{
_isFirstPlayer = isFirstPlayer;
_playerType = _isFirstPlayer ?
Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
_isMultiplay = false;
}
public PlayerState(bool isFirstPlayer, MultiplayController multiplayController, string roomId) // 멀티 플레이용 생성자
:this(isFirstPlayer)
{
// :this(isFirstPlayer)가 아래 주석문을 대신 함
//_isFirstPlayer = isFirstPlayer;
//_playerType = _isFirstPlayer ?
// Constants.PlayerType.PlayerA : Constants.PlayerType.PlayerB;
_multiplayController = multiplayController;
_roomId = roomId;
_isMultiplay = true;
}
public override void HandleMove(GameLogic gameLogic, int row, int col)
{
ProcessMove(gameLogic, _playerType, row, col);
if (_isMultiplay) // 서버에 Marker 정보 전달
{
_multiplayController.DoPlayer(_roomId, row * Constants.BlockColumnCount + col);
}
}
}