diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts index b855763..66b9b40 100644 --- a/apps/client/src/hooks/useAppFlow.ts +++ b/apps/client/src/hooks/useAppFlow.ts @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useCallback, useReducer, useRef, useState } from "react"; import { socketManager } from "@client/network/SocketManager"; import { appConsts, config } from "@repo/shared"; import type { appTypes, roomTypes } from "@repo/shared"; +import { useSocketSubscriptions } from "./useSocketSubscriptions"; type AppFlowState = { scenePhase: appTypes.ScenePhase; @@ -14,30 +15,37 @@ type JoinState = { isJoining: boolean; - joinErrorMessage: string | null; + joinFailure: JoinFailure | null; +}; + +type JoinFailureReason = roomTypes.JoinRoomRejectedPayload["reason"] | "timeout"; + +type JoinFailure = { + reason: JoinFailureReason; + roomId?: string; }; type JoinAction = | { type: "start" } - | { type: "complete"; errorMessage: string | null }; + | { type: "complete"; joinFailure: JoinFailure | null }; const initialJoinState: JoinState = { isJoining: false, - joinErrorMessage: null, + joinFailure: null, }; const joinReducer = (state: JoinState, action: JoinAction): JoinState => { if (action.type === "start") { return { isJoining: true, - joinErrorMessage: null, + joinFailure: null, }; } if (action.type === "complete") { return { isJoining: false, - joinErrorMessage: action.errorMessage, + joinFailure: action.joinFailure, }; } @@ -70,19 +78,27 @@ joinTimeoutRef.current = null; }, []); - const completeJoinRequest = useCallback((errorMessage: string | null = null) => { + const completeJoinRequest = useCallback((joinFailure: JoinFailure | null = null) => { clearJoinTimeout(); clearJoinRejectedHandler(); - dispatchJoin({ type: "complete", errorMessage }); + dispatchJoin({ type: "complete", joinFailure }); }, [clearJoinRejectedHandler, clearJoinTimeout]); - const getJoinRejectedMessage = useCallback((payload: roomTypes.JoinRoomRejectedPayload): string | null => { - if (payload.reason === "full") { - return `ルーム ${payload.roomId} は満員です`; + const getJoinErrorMessage = useCallback((joinFailure: JoinFailure | null): string | null => { + if (!joinFailure) { + return null; } - if (payload.reason === "duplicate") { - return `ルーム ${payload.roomId} への参加要求が重複しました`; + if (joinFailure.reason === "full") { + return `ルーム ${joinFailure.roomId ?? ""} は満員です`; + } + + if (joinFailure.reason === "duplicate") { + return `ルーム ${joinFailure.roomId ?? ""} への参加要求が重複しました`; + } + + if (joinFailure.reason === "timeout") { + return "参加要求がタイムアウトしました,もう一度お試しください"; } return null; @@ -97,49 +113,34 @@ dispatchJoin({ type: "start" }); const handleJoinRejected = (payload: roomTypes.JoinRoomRejectedPayload) => { - const joinRejectedMessage = getJoinRejectedMessage(payload); - completeJoinRequest(joinRejectedMessage); + completeJoinRequest({ + reason: payload.reason, + roomId: payload.roomId, + }); }; joinRejectedHandlerRef.current = handleJoinRejected; socketManager.title.onceJoinRejected(handleJoinRejected); joinTimeoutRef.current = setTimeout(() => { - completeJoinRequest("参加要求がタイムアウトしました,もう一度お試しください"); + completeJoinRequest({ reason: "timeout" }); }, config.GAME_CONFIG.JOIN_REQUEST_TIMEOUT_MS); socketManager.title.joinRoom(payload); - }, [completeJoinRequest, getJoinRejectedMessage, joinState.isJoining]); + }, [completeJoinRequest, joinState.isJoining]); - useEffect(() => { - const handleConnect = (id: string) => { - setMyId(id); - }; - const handleRoomUpdate = (updatedRoom: roomTypes.Room) => { - completeJoinRequest(); - setRoom(updatedRoom); - setScenePhase(appConsts.ScenePhase.LOBBY); - }; - const handleGameStart = () => { - setScenePhase(appConsts.ScenePhase.PLAYING); - }; - - socketManager.common.onConnect(handleConnect); - socketManager.lobby.onRoomUpdate(handleRoomUpdate); - socketManager.game.onceGameStart(handleGameStart); - - return () => { - completeJoinRequest(); - socketManager.common.offConnect(handleConnect); - socketManager.lobby.offRoomUpdate(handleRoomUpdate); - }; - }, [completeJoinRequest]); + useSocketSubscriptions({ + completeJoinRequest, + setMyId, + setRoom, + setScenePhase, + }); return { scenePhase, room, myId, - joinErrorMessage: joinState.joinErrorMessage, + joinErrorMessage: getJoinErrorMessage(joinState.joinFailure), isJoining: joinState.isJoining, requestJoin, }; diff --git a/apps/client/src/hooks/useSocketSubscriptions.ts b/apps/client/src/hooks/useSocketSubscriptions.ts new file mode 100644 index 0000000..a43f0b9 --- /dev/null +++ b/apps/client/src/hooks/useSocketSubscriptions.ts @@ -0,0 +1,44 @@ +import { useEffect } from "react"; +import { socketManager } from "@client/network/SocketManager"; +import { appConsts } from "@repo/shared"; +import type { appTypes, roomTypes } from "@repo/shared"; + +type UseSocketSubscriptionsParams = { + completeJoinRequest: () => void; + setMyId: (id: string | null) => void; + setRoom: (room: roomTypes.Room | null) => void; + setScenePhase: (phase: appTypes.ScenePhase) => void; +}; + +export const useSocketSubscriptions = ({ + completeJoinRequest, + setMyId, + setRoom, + setScenePhase, +}: UseSocketSubscriptionsParams): void => { + useEffect(() => { + const handleConnect = (id: string) => { + setMyId(id); + }; + + const handleRoomUpdate = (updatedRoom: roomTypes.Room) => { + completeJoinRequest(); + setRoom(updatedRoom); + setScenePhase(appConsts.ScenePhase.LOBBY); + }; + + const handleGameStart = () => { + setScenePhase(appConsts.ScenePhase.PLAYING); + }; + + socketManager.common.onConnect(handleConnect); + socketManager.lobby.onRoomUpdate(handleRoomUpdate); + socketManager.game.onceGameStart(handleGameStart); + + return () => { + completeJoinRequest(); + socketManager.common.offConnect(handleConnect); + socketManager.lobby.offRoomUpdate(handleRoomUpdate); + }; + }, [completeJoinRequest, setMyId, setRoom, setScenePhase]); +}; diff --git a/apps/client/src/network/handlers/CommonHandler.ts b/apps/client/src/network/handlers/CommonHandler.ts index 76b798a..a9820f7 100644 --- a/apps/client/src/network/handlers/CommonHandler.ts +++ b/apps/client/src/network/handlers/CommonHandler.ts @@ -1,6 +1,6 @@ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; -import type { PayloadOf } from "@repo/shared"; +import type { ConnectionLifecyclePayloadOf } from "@repo/shared"; import { createClientSocketEventBridge } from "./socketEventBridge"; type CommonHandler = { @@ -11,7 +11,7 @@ export const createCommonHandler = (socket: Socket): CommonHandler => { const connectListenerMap = new Map< (id: string) => void, - (payload: PayloadOf) => void + (payload: ConnectionLifecyclePayloadOf) => void >(); const { onEvent, offEvent } = createClientSocketEventBridge(socket); @@ -22,7 +22,7 @@ callback(socket.id || ""); } - const listener = (_payload: PayloadOf) => { + const listener = (_payload: ConnectionLifecyclePayloadOf) => { callback(socket.id || ""); }; diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index 9b1c594..031ed4c 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -1,12 +1,12 @@ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; -import type { PayloadOf } from "@repo/shared"; +import type { ServerToClientPayloadOf } from "@repo/shared"; import { createClientSocketEventBridge } from "./socketEventBridge"; type LobbyHandler = { - onRoomUpdate: (callback: (room: PayloadOf) => void) => void; - onceRoomUpdate: (callback: (room: PayloadOf) => void) => void; - offRoomUpdate: (callback: (room: PayloadOf) => void) => void; + onRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; + onceRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; + offRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; startGame: () => void; }; diff --git a/apps/client/src/network/handlers/TitleHandler.ts b/apps/client/src/network/handlers/TitleHandler.ts index 04d3a91..d3c23ad 100644 --- a/apps/client/src/network/handlers/TitleHandler.ts +++ b/apps/client/src/network/handlers/TitleHandler.ts @@ -1,13 +1,13 @@ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; -import type { PayloadOf } from "@repo/shared"; +import type { ClientToServerPayloadOf, ServerToClientPayloadOf } from "@repo/shared"; import { createClientSocketEventBridge } from "./socketEventBridge"; type TitleHandler = { - joinRoom: (payload: PayloadOf) => void; - onJoinRejected: (callback: (payload: PayloadOf) => void) => void; - onceJoinRejected: (callback: (payload: PayloadOf) => void) => void; - offJoinRejected: (callback: (payload: PayloadOf) => void) => void; + joinRoom: (payload: ClientToServerPayloadOf) => void; + onJoinRejected: (callback: (payload: ServerToClientPayloadOf) => void) => void; + onceJoinRejected: (callback: (payload: ServerToClientPayloadOf) => void) => void; + offJoinRejected: (callback: (payload: ServerToClientPayloadOf) => void) => void; }; export const createTitleHandler = (socket: Socket): TitleHandler => { diff --git a/apps/client/src/network/handlers/socketEventBridge.ts b/apps/client/src/network/handlers/socketEventBridge.ts index aead5bc..c7daa19 100644 --- a/apps/client/src/network/handlers/socketEventBridge.ts +++ b/apps/client/src/network/handlers/socketEventBridge.ts @@ -1,40 +1,20 @@ import type { Socket } from "socket.io-client"; -import type { PayloadOf, SocketPayloadMap } from "@repo/shared"; +import { + createSocketEventBridge, + type ClientToServerEventPayloadMap, + type ConnectionLifecycleEventPayloadMap, + type ServerToClientEventPayloadMap, +} from "@repo/shared"; -type SocketEventName = keyof SocketPayloadMap; +type ClientInboundEventPayloadMap = + & ConnectionLifecycleEventPayloadMap + & ServerToClientEventPayloadMap; export const createClientSocketEventBridge = (socket: Socket) => { - const onEvent = ( - event: TEvent, - callback: (payload: PayloadOf) => void - ) => { - (socket as any).on(event, callback); - }; - - const onceEvent = ( - event: TEvent, - callback: (payload: PayloadOf) => void - ) => { - (socket as any).once(event, callback); - }; - - const offEvent = ( - event: TEvent, - callback: (payload: PayloadOf) => void - ) => { - (socket as any).off(event, callback); - }; - - function emitEvent(event: TEvent): void; - function emitEvent(event: TEvent, payload: PayloadOf): void; - function emitEvent(event: TEvent, payload?: PayloadOf): void { - if (payload === undefined) { - (socket as any).emit(event); - return; - } - - (socket as any).emit(event, payload); - } + const { onEvent, onceEvent, offEvent, emitEvent } = createSocketEventBridge< + ClientInboundEventPayloadMap, + ClientToServerEventPayloadMap + >(socket as any); return { onEvent, diff --git a/apps/server/src/network/adapters/socketEmitters.ts b/apps/server/src/network/adapters/socketEmitters.ts index fd5be3b..e6b7e4d 100644 --- a/apps/server/src/network/adapters/socketEmitters.ts +++ b/apps/server/src/network/adapters/socketEmitters.ts @@ -3,23 +3,26 @@ * Socket.IOの送信処理を用途別に生成するアダプタ */ import { Server, Socket } from "socket.io"; -import type { PayloadOf, SocketPayloadMap } from "@repo/shared"; +import type { + ServerToClientEventPayloadMap, + ServerToClientPayloadOf, +} from "@repo/shared"; -type SocketEventName = keyof SocketPayloadMap; +type SocketEventName = keyof ServerToClientEventPayloadMap; type EmitToRoom = { (roomId: string, event: TEvent): void; - (roomId: string, event: TEvent, payload: PayloadOf): void; + (roomId: string, event: TEvent, payload: ServerToClientPayloadOf): void; }; type EmitToSocket = { (event: TEvent): void; - (event: TEvent, payload: PayloadOf): void; + (event: TEvent, payload: ServerToClientPayloadOf): void; }; type EmitToAll = { (event: TEvent): void; - (event: TEvent, payload: PayloadOf): void; + (event: TEvent, payload: ServerToClientPayloadOf): void; }; const emitWithOptionalPayload = ( diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index 5b1b631..da086c4 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -21,6 +21,11 @@ import { createServerSocketOnBridge } from "@server/network/handlers/socketEventBridge"; import { createGameOutputAdapter } from "./createGameOutputAdapter"; +const gamePayloadValidators = { + [protocol.SocketEvents.PING]: isPingPayload, + [protocol.SocketEvents.MOVE]: isMovePayload, +} as const; + /** ゲームイベントの購読とユースケース呼び出しを設定する */ export const registerGameHandlers = ( io: Server, @@ -34,7 +39,7 @@ // 遅延計測用のPINGを検証しPONGを返す onEvent(protocol.SocketEvents.PING, (clientTime) => { - if (!isPingPayload(clientTime)) { + if (!gamePayloadValidators[protocol.SocketEvents.PING](clientTime)) { logEvent("Network", { event: "PING", result: "ignored_invalid_payload", @@ -71,7 +76,7 @@ // 移動入力を検証しプレイヤー移動ユースケースへ連携する onEvent(protocol.SocketEvents.MOVE, (data) => { - if (!isMovePayload(data)) { + if (!gamePayloadValidators[protocol.SocketEvents.MOVE](data)) { logEvent("Network", { event: "MOVE", result: "ignored_invalid_payload", diff --git a/apps/server/src/network/handlers/room/registerRoomHandlers.ts b/apps/server/src/network/handlers/room/registerRoomHandlers.ts index e799931..8c3bdbd 100644 --- a/apps/server/src/network/handlers/room/registerRoomHandlers.ts +++ b/apps/server/src/network/handlers/room/registerRoomHandlers.ts @@ -12,6 +12,10 @@ import { isJoinRoomPayload } from "@server/network/validation/socketPayloadValidators"; import { createRoomOutputAdapter } from "./createRoomOutputAdapter"; +const roomPayloadValidators = { + [protocol.SocketEvents.JOIN_ROOM]: isJoinRoomPayload, +} as const; + /** ルーム参加イベントを検証して参加ユースケースへ連携する */ export const registerRoomHandlers = ( io: Server, @@ -24,7 +28,7 @@ // 参加要求のペイロード検証と参加処理を実行する onEvent(protocol.SocketEvents.JOIN_ROOM, async (data) => { - if (!isJoinRoomPayload(data)) { + if (!roomPayloadValidators[protocol.SocketEvents.JOIN_ROOM](data)) { logEvent("Network", { event: "JOIN_ROOM", result: "ignored_invalid_payload", diff --git a/apps/server/src/network/handlers/socketEventBridge.ts b/apps/server/src/network/handlers/socketEventBridge.ts index 1db3c62..e3a6a0d 100644 --- a/apps/server/src/network/handlers/socketEventBridge.ts +++ b/apps/server/src/network/handlers/socketEventBridge.ts @@ -1,22 +1,15 @@ import type { Socket } from "socket.io"; -import type { PayloadOf, SocketPayloadMap } from "@repo/shared"; - -type SocketEventName = Exclude; +import { + createSocketEventBridge, + type ClientToServerEventPayloadMap, + type ServerToClientEventPayloadMap, +} from "@repo/shared"; export const createServerSocketOnBridge = (socket: Socket) => { - const onEvent = ( - event: TEvent, - callback: (payload: PayloadOf) => void - ) => { - (socket as any).on(event, callback); - }; - - const onceEvent = ( - event: TEvent, - callback: (payload: PayloadOf) => void - ) => { - (socket as any).once(event, callback); - }; + const { onEvent, onceEvent } = createSocketEventBridge< + ClientToServerEventPayloadMap, + ServerToClientEventPayloadMap + >(socket as any); return { onEvent, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 14354f3..f911328 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,10 @@ export * as roomConsts from "./domains/room/room.const"; export * as protocol from "./protocol/events"; export type { + ClientToServerEventPayloadMap, + ClientToServerPayloadOf, + ConnectionLifecycleEventPayloadMap, + ConnectionLifecyclePayloadOf, CurrentPlayersPayload, GameStartPayload, MovePayload, @@ -17,8 +21,11 @@ PingPayload, PongPayload, RemovePlayerPayload, + ServerToClientEventPayloadMap, + ServerToClientPayloadOf, SocketPayloadMap, UpdateMapCellsPayload, UpdatePlayersPayload, } from "./protocol/events"; +export { createSocketEventBridge } from "./protocol/socketEventBridge"; export * as config from "./config"; \ No newline at end of file diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index a0e3939..7a88555 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -67,25 +67,51 @@ }; /** ソケットイベントごとのペイロード対応表 */ -export type SocketPayloadMap = { +export type ConnectionLifecycleEventPayloadMap = { [SocketEvents.CONNECT]: undefined; [SocketEvents.DISCONNECT]: undefined; +}; + +/** クライアントからサーバーへ送信するイベントごとのペイロード対応表 */ +export type ClientToServerEventPayloadMap = { [SocketEvents.JOIN_ROOM]: roomTypes.JoinRoomPayload; + [SocketEvents.START_GAME]: undefined; + [SocketEvents.READY_FOR_GAME]: undefined; + [SocketEvents.MOVE]: MovePayload; + [SocketEvents.PING]: PingPayload; +}; + +/** サーバーからクライアントへ送信するイベントごとのペイロード対応表 */ +export type ServerToClientEventPayloadMap = { [SocketEvents.ROOM_JOIN_REJECTED]: roomTypes.JoinRoomRejectedPayload; [SocketEvents.ROOM_UPDATE]: roomTypes.Room; - [SocketEvents.START_GAME]: undefined; [SocketEvents.GAME_START]: GameStartPayload; - [SocketEvents.READY_FOR_GAME]: undefined; [SocketEvents.CURRENT_PLAYERS]: CurrentPlayersPayload; [SocketEvents.NEW_PLAYER]: NewPlayerPayload; [SocketEvents.UPDATE_PLAYERS]: UpdatePlayersPayload; [SocketEvents.REMOVE_PLAYER]: RemovePlayerPayload; - [SocketEvents.MOVE]: MovePayload; [SocketEvents.UPDATE_MAP_CELLS]: UpdateMapCellsPayload; - [SocketEvents.PING]: PingPayload; [SocketEvents.PONG]: PongPayload; [SocketEvents.GAME_END]: undefined; }; +/** 後方互換のための統合イベントマップ */ +export type SocketPayloadMap = + & ConnectionLifecycleEventPayloadMap + & ClientToServerEventPayloadMap + & ServerToClientEventPayloadMap; + +/** クライアント送信イベントのペイロード型を取得するユーティリティ */ +export type ClientToServerPayloadOf = + ClientToServerEventPayloadMap[TEvent]; + +/** サーバー送信イベントのペイロード型を取得するユーティリティ */ +export type ServerToClientPayloadOf = + ServerToClientEventPayloadMap[TEvent]; + +/** 接続ライフサイクルイベントのペイロード型を取得するユーティリティ */ +export type ConnectionLifecyclePayloadOf = + ConnectionLifecycleEventPayloadMap[TEvent]; + /** 指定イベント名に対応するペイロード型を取得するユーティリティ */ export type PayloadOf = SocketPayloadMap[TEvent]; diff --git a/packages/shared/src/protocol/socketEventBridge.ts b/packages/shared/src/protocol/socketEventBridge.ts new file mode 100644 index 0000000..f171a3f --- /dev/null +++ b/packages/shared/src/protocol/socketEventBridge.ts @@ -0,0 +1,57 @@ +type EventPayloadMap = Record; + +type EventNameOf = Extract; + +type SocketBridgeTarget = { + on: (event: string, callback: (payload: unknown) => void) => void; + once: (event: string, callback: (payload: unknown) => void) => void; + off: (event: string, callback: (payload: unknown) => void) => void; + emit: (event: string, payload?: unknown) => void; +}; + +/** + * 受信・送信イベントの型マップを指定して、Socket.IOブリッジを生成する + */ +export const createSocketEventBridge = < + TInboundMap extends EventPayloadMap, + TOutboundMap extends EventPayloadMap, +>(socket: SocketBridgeTarget) => { + const onEvent = >( + event: TEvent, + callback: (payload: TInboundMap[TEvent]) => void + ) => { + (socket as any).on(event, callback); + }; + + const onceEvent = >( + event: TEvent, + callback: (payload: TInboundMap[TEvent]) => void + ) => { + (socket as any).once(event, callback); + }; + + const offEvent = >( + event: TEvent, + callback: (payload: TInboundMap[TEvent]) => void + ) => { + (socket as any).off(event, callback); + }; + + function emitEvent>(event: TEvent): void; + function emitEvent>(event: TEvent, payload: TOutboundMap[TEvent]): void; + function emitEvent>(event: TEvent, payload?: TOutboundMap[TEvent]): void { + if (payload === undefined) { + (socket as any).emit(event); + return; + } + + (socket as any).emit(event, payload); + } + + return { + onEvent, + onceEvent, + offEvent, + emitEvent, + }; +};