Game Socket 에는 다양한 역할들이 들어가 있었다.
우선 roomJoin을 클라이언트에서 서버로 보내게 되면 소켓을 통해 서버에서 roonJoin 응답(?)이 오게 된다. 이 응답에는 PlayerID가 들어가 있고 해당 정보를 바탕으로 클라이언트에서는 로컬스토리지에 PlayerID를 저장하여 이용하게 된다.
이런 roomJoin 외에도 재연결 로직, 게임 시작, 호스트 정보, 게임방 설정, 방 상태 등등 많은 정보들이 Game Socket을 통해서 전달되고 Game Socket Handler, Game Socket Hook 을 통해서 이벤트가 등록되고 사용되었다.
메시지 전송/ 수신의 로직만 존재하던 Chat Socket에 비해 많은 이벤트 전송/수신이 필요하기 때문에 Game Socket에 해당 하는 Domain Manager는 복잡할 것으로 예상된다. 하지만 전체적인 Shared Worker 구조를 이전에 그렸기 때문에 이를 기반으로 구현을 하면 다음과 같다.
1차 구현
import { SocketNamespace } from '@/stores/socket/socket.config';
import {
CheckAnswerRequest,
CRDTSyncMessage,
JoinRoomRequest,
JoinRoomResponse,
PlayerLeftResponse,
ReconnectRequest,
RoomEndResponse,
RoundEndResponse,
RoundStartResponse,
TimerSyncResponse,
UpdateSettingsRequest,
UpdateSettingsResponse,
} from '@troublepainter/core';
type GameEventMap = {
joinedRoom: (response: JoinRoomResponse) => void;
playerJoined: (response: JoinRoomResponse) => void;
playerLeft: (response: PlayerLeftResponse) => void;
settingsUpdated: (response: UpdateSettingsResponse) => void;
drawingGroupRoundStarted: (response: RoundStartResponse) => void;
guesserRoundStarted: (response: RoundStartResponse) => void;
timerSync: (response: TimerSyncResponse) => void;
drawingTimeEnded: () => void;
roundEnded: (response: RoundEndResponse) => void;
gameEnded: (response: RoomEndResponse) => void;
};
class GameWorkerManager {
private static instance: GameWorkerManager;
private worker: SharedWorker | null = null;
private connected: boolean = false;
private eventHandlers: Map<keyof GameEventMap, Set<Function>> = new Map();
private constructor() {
console.log('Initializing ChatWorkerManager...');
try {
this.worker = new SharedWorker(new URL('./socketWorker.ts', import.meta.url), {
type: 'module',
name: 'socket-worker',
});
this.setupWorkerListeners();
this.worker.port.start();
} catch (error) {
console.error('Error initializing ChatWorkerManager:', error);
}
}
private setupWorkerListeners() {
if (!this.worker) return;
this.worker.port.onmessage = (e) => {
const { type, namespace, connected, event, args } = e.data;
// GAME 네임스페이스 이벤트만 처리
if (type === 'connection_update' || type === 'socket_event' || type === 'socket_error') {
if (namespace !== SocketNamespace.GAME) return;
}
switch (type) {
case 'init':
this.connected = e.data.connected[SocketNamespace.GAME] || false;
break;
case 'connection_update':
console.log('Game connection status:', connected);
this.connected = connected;
break;
case 'socket_event':
const handlers = this.eventHandlers.get(event as keyof GameEventMap);
if (handlers) {
handlers.forEach((handler) => handler(...args));
}
break;
case 'socket_error':
console.error('Game socket error:', e.data.error);
break;
}
};
}
public static getInstance(): GameWorkerManager {
if (!GameWorkerManager.instance) {
GameWorkerManager.instance = new GameWorkerManager();
}
return GameWorkerManager.instance;
}
// 이벤트 리스너 등록 메서드
public on<T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)!.add(handler);
}
// 이벤트 리스너 제거 메서드
public off<T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(handler);
}
}
public connect() {
if (!this.worker) {
console.error('Game Worker not initialized');
return;
}
console.log('Connecting to game socket');
this.worker.port.postMessage({
type: 'connect',
payload: {
namespace: SocketNamespace.GAME,
},
});
}
public disconnect() {
if (!this.worker) return;
console.log('Disconnecting from game socket...');
this.worker.port.postMessage({
type: 'disconnect',
payload: {
namespace: SocketNamespace.GAME,
},
});
this.connected = false;
}
// Event handling
private emitEvent(event: string, ...args: any[]) {
if (!this.worker) {
console.error('Worker not initialized');
return;
}
if (!this.connected) {
console.warn('Game socket is not connected');
return;
}
this.worker.port.postMessage({
type: 'emit',
payload: {
namespace: SocketNamespace.GAME,
event,
args,
},
});
}
public joinRoom(request: JoinRoomRequest) {
this.emitEvent('joinRoom', request);
}
public reconnectRoom(request: ReconnectRequest) {
this.emitEvent('reconnect', request);
}
public updateSetting(request: UpdateSettingsRequest) {
this.emitEvent('updateSettings', request);
}
public gameStart() {
this.emitEvent('gameStart');
}
public checkAnswer(request: CheckAnswerRequest) {
this.emitEvent('checkAnswer', request);
}
public submittedDrawing(drawing: CRDTSyncMessage) {
this.emitEvent('submittedDrawing', { drawing });
}
}
export const gameWorkerManager = GameWorkerManager.getInstance();
export const onGameEvent = <T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) =>
gameWorkerManager.on(event, handler);
export const offGameEvent = <T extends keyof GameEventMap>(event: T, handler: GameEventMap[T]) =>
gameWorkerManager.off(event, handler);
export const gameSocketConnect = () => gameWorkerManager.connect();
export const gameSocketDisconnect = () => gameWorkerManager.disconnect();
export const gameWorkerJoinRoom = (request: JoinRoomRequest) => gameWorkerManager.joinRoom(request);
export const gameWorkerReconnectRoom = (request: ReconnectRequest) => gameWorkerManager.reconnectRoom(request);
export const gameWorkerUpdateSetting = (request: UpdateSettingsRequest) => gameWorkerManager.updateSetting(request);
export const gameWorkerGameStart = () => gameWorkerManager.gameStart();
export const gameWorkerCheckAnswer = (request: CheckAnswerRequest) => gameWorkerManager.checkAnswer(request);
export const gameWorkerSubmitDrawing = (drawing: CRDTSyncMessage) => gameWorkerManager.submittedDrawing(drawing);
이제 해당 로직을 Hook 과 Handler 에 적용시켜 사용했는데 Game Socket에 연결하는 시간이 걸리는 것으로 확인 되었다.
연결을 요청하고 바로 joinRoom 이벤트를 요청하면 소켓 연결이 되어있지 않기 때문에 이벤트를 보낼 수 없다고 나오는 것을 확인했다. 따라서 joinRoom 의 로직에 setTimeout 을 이용해 100ms 의 딜레이를 주었다.
앞서 해결한 내용과 관련되어 StrictMode 에 의해 코드가 2번씩 실행되기 때문에 한가지 문제가 발생하게 된다. 문제 상황에 대해 간략하게 적으면 다음과 같다.