diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index 00c037b..49ff35c 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -5,7 +5,11 @@ */ import type { Socket } from "socket.io-client"; import { contracts as protocol } from "@repo/shared"; -import type { ServerToClientPayloadOf, StartGameRequestPayload } from "@repo/shared"; +import type { + LobbySettingsUpdatePayload, + ServerToClientPayloadOf, + StartGameRequestPayload, +} from "@repo/shared"; import { createClientSocketEventBridge } from "./socketEventBridge"; /** ロビー画面で利用する通信操作の契約 */ @@ -26,6 +30,7 @@ ) => void, ) => void; startGame: (payload?: StartGameRequestPayload) => void; + updateLobbySettings: (payload: LobbySettingsUpdatePayload) => void; }; /** ロビー画面向けのソケットハンドラを生成する */ @@ -46,6 +51,9 @@ startGame: (payload) => { emitEvent(protocol.SocketEvents.START_GAME, payload ?? {}); }, + updateLobbySettings: (payload) => { + emitEvent(protocol.SocketEvents.LOBBY_SETTINGS_UPDATE, payload); + }, }; }; diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx index 237bf95..8b22573 100644 --- a/apps/client/src/scenes/lobby/LobbyScene.tsx +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -2,13 +2,18 @@ import { domain } from "@repo/shared"; import type { FieldSizePreset, StartGameRequestPayload } from "@repo/shared"; import { config } from "@client/config"; +import { socketManager } from "@client/network/SocketManager"; import { OVERLAY_BUTTON_STYLE } from "@client/scenes/shared/styles/overlayStyles"; import { LobbyRuleModal } from "./components/LobbyRuleModal"; +import { LobbyStartConfirmModal } from "./components/LobbyStartConfirmModal"; import { LOBBY_BACK_BUTTON_STYLE, LOBBY_BACKGROUND_STYLE, LOBBY_CONTAINER_STYLE, LOBBY_CONTROLS_BLOCK_STYLE, + LOBBY_HOST_SETTINGS_LABEL_STYLE, + LOBBY_HOST_SETTINGS_STYLE, + LOBBY_HOST_SETTINGS_VALUE_STYLE, LOBBY_LABEL_STYLE, LOBBY_LEFT_INNER_STYLE, LOBBY_LEFT_PANEL_STYLE, @@ -72,6 +77,7 @@ const [selectedFieldSizePreset, setSelectedFieldSizePreset] = useState(config.GAME_CONFIG.DEFAULT_FIELD_PRESET); const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); + const [isStartConfirmVisible, setIsStartConfirmVisible] = useState(false); useEffect(() => { setSelectedStartPlayerCount((prev) => { @@ -87,13 +93,34 @@ }); }, [minimumStartPlayerCount, maxStartPlayerCount]); - const handleStart = () => { + // ホストが設定を変更したらサーバーに通知して全員に反映する + useEffect(() => { + if (!isMeOwner) { + return; + } + + socketManager.lobby.updateLobbySettings({ + targetPlayerCount: selectedStartPlayerCount, + fieldSizePreset: selectedFieldSizePreset, + }); + }, [isMeOwner, selectedStartPlayerCount, selectedFieldSizePreset]); + + const handleStartClick = () => { + setIsStartConfirmVisible(true); + }; + + const handleStartConfirm = () => { + setIsStartConfirmVisible(false); onStart({ targetPlayerCount: selectedStartPlayerCount, fieldSizePreset: selectedFieldSizePreset, }); }; + const handleStartCancel = () => { + setIsStartConfirmVisible(false); + }; + const toFieldPresetLabel = (preset: FieldSizePreset): string => { const range = config.GAME_CONFIG.FIELD_PRESETS[preset].recommendedPlayers; const baseLabel = @@ -191,7 +218,7 @@ ))} - @@ -208,6 +235,23 @@ ホストの開始を待っています... +
+
+
ゲーム人数
+
+ {room.targetPlayerCount != null + ? `${room.targetPlayerCount}人` + : "未設定"} +
+
+
+
フィールドサイズ
+
+ {toFieldPresetLabel(room.fieldSizePreset)} +
+
+
+ + + + + + ); +}; diff --git a/apps/client/src/scenes/lobby/styles/LobbyScene.styles.ts b/apps/client/src/scenes/lobby/styles/LobbyScene.styles.ts index b93d465..6ca66a1 100644 --- a/apps/client/src/scenes/lobby/styles/LobbyScene.styles.ts +++ b/apps/client/src/scenes/lobby/styles/LobbyScene.styles.ts @@ -112,6 +112,31 @@ borderRadius: "8px", fontWeight: "bold", boxShadow: "0 4px 6px rgba(0,0,0,0.3)", + marginTop: "clamp(8px, 2dvh, 16px)", +}; + + +/** 非オーナー向けホスト設定表示カードのスタイル */ +export const LOBBY_HOST_SETTINGS_STYLE: CSSProperties = { + padding: "12px 16px", + backgroundColor: "rgba(0,0,0,0.5)", + border: "1px solid rgba(255,255,255,0.2)", + borderRadius: "8px", + display: "flex", + flexDirection: "column", + gap: "6px", +}; + +/** 非オーナー向けホスト設定ラベルのスタイル */ +export const LOBBY_HOST_SETTINGS_LABEL_STYLE: CSSProperties = { + fontSize: "0.8rem", + color: "#aaa", +}; + +/** 非オーナー向けホスト設定値のスタイル */ +export const LOBBY_HOST_SETTINGS_VALUE_STYLE: CSSProperties = { + fontSize: "1.1rem", + fontWeight: 700, }; /** 非オーナー待機表示のスタイル */ diff --git a/apps/server/src/domains/room/RoomManager.ts b/apps/server/src/domains/room/RoomManager.ts index 67b532c..8c38f8c 100644 --- a/apps/server/src/domains/room/RoomManager.ts +++ b/apps/server/src/domains/room/RoomManager.ts @@ -63,6 +63,22 @@ return this.roomPhaseService.markRoomWaiting(roomId); } + // ロビー設定(ゲーム人数・フィールドサイズ)を更新してルームを返す + public updateLobbySettings( + roomId: string, + targetPlayerCount: number, + fieldSizePreset: domain.room.Room["fieldSizePreset"], + ): domain.room.Room | undefined { + const room = this.rooms.get(roomId); + if (!room || room.status !== domain.room.RoomPhase.WAITING) { + return undefined; + } + + room.targetPlayerCount = targetPlayerCount; + room.fieldSizePreset = fieldSizePreset; + return room; + } + // ルームを削除する public deleteRoom(roomId: string): boolean { return this.rooms.delete(roomId); diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index 8c27e69..f6c135d 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -85,6 +85,15 @@ deleteRoom(roomId: string): boolean; } +/** ロビー設定更新操作ポート */ +export interface UpdateLobbySettingsPort { + updateLobbySettings( + roomId: string, + targetPlayerCount: number, + fieldSizePreset: domain.room.Room["fieldSizePreset"], + ): domain.room.Room | undefined; +} + /** ルーム参加後にゲームランタイムを確保する操作ポート */ export interface EnsureGameRuntimePort { ensureGameManagerForRoom(roomId: string): void; diff --git a/apps/server/src/logging/contracts/payloadByScope.ts b/apps/server/src/logging/contracts/payloadByScope.ts index 044d3d0..05ffae0 100644 --- a/apps/server/src/logging/contracts/payloadByScope.ts +++ b/apps/server/src/logging/contracts/payloadByScope.ts @@ -68,6 +68,13 @@ socketId: string; }; +/** NetworkのLOBBY_SETTINGS_UPDATE不正ペイロードログ契約 */ +type NetworkLobbySettingsUpdateLogPayload = { + event: typeof protocol.SocketEvents.LOBBY_SETTINGS_UPDATE; + result: typeof logResults.IGNORED_INVALID_PAYLOAD; + socketId: string; +}; + /** Networkスコープのログ契約ユニオン */ type NetworkLogPayload = | NetworkConnectLogPayload @@ -76,7 +83,8 @@ | NetworkPingLogPayload | NetworkMoveLogPayload | NetworkPlaceBombLogPayload - | NetworkBombHitReportLogPayload; + | NetworkBombHitReportLogPayload + | NetworkLobbySettingsUpdateLogPayload; /** GameUseCaseのSTART_GAMEログ契約 */ type GameUseCaseStartGameLogPayload = { diff --git a/apps/server/src/network/handlers/payloadGuard.ts b/apps/server/src/network/handlers/payloadGuard.ts index 3bd6936..ea3e8d5 100644 --- a/apps/server/src/network/handlers/payloadGuard.ts +++ b/apps/server/src/network/handlers/payloadGuard.ts @@ -31,6 +31,10 @@ event: protocol.SocketEvents.BOMB_HIT_REPORT, result: logResults.IGNORED_INVALID_PAYLOAD, }, + [protocol.SocketEvents.LOBBY_SETTINGS_UPDATE]: { + event: protocol.SocketEvents.LOBBY_SETTINGS_UPDATE, + result: logResults.IGNORED_INVALID_PAYLOAD, + }, } as const; type PayloadGuardEventName = keyof typeof invalidPayloadLogByEvent; diff --git a/apps/server/src/network/handlers/room/registerRoomHandlers.ts b/apps/server/src/network/handlers/room/registerRoomHandlers.ts index bb3f083..92d2bde 100644 --- a/apps/server/src/network/handlers/room/registerRoomHandlers.ts +++ b/apps/server/src/network/handlers/room/registerRoomHandlers.ts @@ -7,29 +7,38 @@ import type { JoinRoomEventRoomUseCasePort, JoinRoomEventRuntimeUseCasePort, + LobbySettingsUpdateEventRoomUseCasePort, } from "@server/network/types/connectionPorts"; import { createSocketRegistrationContext } from "@server/network/handlers/registration"; -import { isJoinRoomPayload } from "@server/network/validation/socketPayloadValidators"; +import { + isJoinRoomPayload, + isLobbySettingsUpdatePayload, +} from "@server/network/validation/socketPayloadValidators"; import type { RoomOutputAdapter } from "./createRoomOutputAdapter"; +import type { LobbySettingsUpdatePayload } from "@repo/shared"; import { handleJoinRoomEvent, + handleLobbySettingsUpdateEvent, type JoinRoomEventPayload, type JoinRoomOrchestratorDeps, + type LobbySettingsUpdateOrchestratorDeps, } from "./roomEventOrchestrators"; import { registerGuardedEvent, type GuardedEventDefinition, } from "@server/network/handlers/eventDefinitionRegistrar"; +type RoomHandlerRoomUseCasePort = JoinRoomEventRoomUseCasePort & LobbySettingsUpdateEventRoomUseCasePort; + type JoinRoomEventDefinition = GuardedEventDefinition< typeof protocol.SocketEvents.JOIN_ROOM, JoinRoomEventPayload >; -/** ルーム受信イベントごとの入力検証関数を保持するテーブル */ -const roomPayloadValidators = { - [protocol.SocketEvents.JOIN_ROOM]: isJoinRoomPayload, -} as const; +type LobbySettingsUpdateEventDefinition = GuardedEventDefinition< + typeof protocol.SocketEvents.LOBBY_SETTINGS_UPDATE, + LobbySettingsUpdatePayload +>; /** ルームイベント調停で利用する依存束を生成する */ const createJoinRoomOrchestratorDeps = ( @@ -49,38 +58,66 @@ }; }; +/** ロビー設定更新イベント調停で利用する依存束を生成する */ +const createLobbySettingsUpdateOrchestratorDeps = ( + socket: Socket, + roomManager: LobbySettingsUpdateEventRoomUseCasePort, + roomOutputAdapter: RoomOutputAdapter, +): LobbySettingsUpdateOrchestratorDeps => { + return { + socketId: socket.id, + roomManager, + output: roomOutputAdapter, + }; +}; + /** JOIN_ROOMイベント定義を生成する */ const createJoinRoomEventDefinition = ( deps: JoinRoomOrchestratorDeps, ): JoinRoomEventDefinition => { return { event: protocol.SocketEvents.JOIN_ROOM, - validator: roomPayloadValidators[protocol.SocketEvents.JOIN_ROOM], + validator: isJoinRoomPayload, orchestrate: async (payload) => { await handleJoinRoomEvent(deps, payload); }, }; }; -/** ルーム参加イベントを検証して参加ユースケースへ連携する */ +/** LOBBY_SETTINGS_UPDATEイベント定義を生成する */ +const createLobbySettingsUpdateEventDefinition = ( + deps: LobbySettingsUpdateOrchestratorDeps, +): LobbySettingsUpdateEventDefinition => { + return { + event: protocol.SocketEvents.LOBBY_SETTINGS_UPDATE, + validator: isLobbySettingsUpdatePayload, + orchestrate: (payload) => { + handleLobbySettingsUpdateEvent(deps, payload); + }, + }; +}; + +/** ルーム関連イベントを検証してユースケースへ連携する */ export const registerRoomHandlers = ( socket: Socket, - roomManager: JoinRoomEventRoomUseCasePort, + roomManager: RoomHandlerRoomUseCasePort, runtimeRegistry: JoinRoomEventRuntimeUseCasePort, roomOutputAdapter: RoomOutputAdapter, ) => { - const orchestratorDeps = createJoinRoomOrchestratorDeps( + const { onEvent, guardOnEvent } = createSocketRegistrationContext(socket); + + const joinRoomDeps = createJoinRoomOrchestratorDeps( socket, roomManager, runtimeRegistry, roomOutputAdapter, ); - const { onEvent, guardOnEvent } = createSocketRegistrationContext(socket); - - // 検証が必要なイベントを宣言的に登録する - const joinRoomEventDefinition = createJoinRoomEventDefinition( - orchestratorDeps, + const lobbySettingsDeps = createLobbySettingsUpdateOrchestratorDeps( + socket, + roomManager, + roomOutputAdapter, ); - registerGuardedEvent(onEvent, guardOnEvent, joinRoomEventDefinition); + registerGuardedEvent(onEvent, guardOnEvent, createJoinRoomEventDefinition(joinRoomDeps)); + registerGuardedEvent(onEvent, guardOnEvent, createLobbySettingsUpdateEventDefinition(lobbySettingsDeps)); }; diff --git a/apps/server/src/network/handlers/room/roomEventOrchestrators.ts b/apps/server/src/network/handlers/room/roomEventOrchestrators.ts index 4fbd750..c0eeb23 100644 --- a/apps/server/src/network/handlers/room/roomEventOrchestrators.ts +++ b/apps/server/src/network/handlers/room/roomEventOrchestrators.ts @@ -5,12 +5,14 @@ * 本ファイルではランタイム未解決ログ対象イベントを扱わない */ import { domain } from "@repo/shared"; +import type { LobbySettingsUpdatePayload } from "@repo/shared"; import { joinRoomUseCase } from "@server/domains/room/application/useCases/joinRoomUseCase"; import { logEvent } from "@server/logging/logger"; import { logResults, logScopes, roomUseCaseLogEvents } from "@server/logging/index"; import type { JoinRoomEventRoomUseCasePort, JoinRoomEventRuntimeUseCasePort, + LobbySettingsUpdateEventRoomUseCasePort, } from "@server/network/types/connectionPorts"; import type { RoomOutputAdapter } from "./createRoomOutputAdapter"; @@ -26,6 +28,35 @@ /** JOIN_ROOMイベントの入力ペイロード型 */ export type JoinRoomEventPayload = Parameters[1]; +/** LOBBY_SETTINGS_UPDATEイベント調停で利用する依存集合 */ +export type LobbySettingsUpdateOrchestratorDeps = { + socketId: string; + roomManager: LobbySettingsUpdateEventRoomUseCasePort; + output: RoomOutputAdapter; +}; + +/** LOBBY_SETTINGS_UPDATEイベントを調停してルーム設定を更新し全員に通知する */ +export const handleLobbySettingsUpdateEvent = ( + deps: LobbySettingsUpdateOrchestratorDeps, + payload: LobbySettingsUpdatePayload, +): void => { + const room = deps.roomManager.getRoomByOwnerId(deps.socketId); + if (!room) { + return; + } + + const updatedRoom = deps.roomManager.updateLobbySettings( + room.roomId, + payload.targetPlayerCount, + payload.fieldSizePreset, + ); + if (!updatedRoom) { + return; + } + + deps.output.publishRoomUpdateToRoom(room.roomId, updatedRoom); +}; + /** JOIN_ROOMイベントを調停して参加ユースケースを実行する */ export const handleJoinRoomEvent = async ( deps: JoinRoomOrchestratorDeps, diff --git a/apps/server/src/network/types/connectionPorts.ts b/apps/server/src/network/types/connectionPorts.ts index 70f9853..734602e 100644 --- a/apps/server/src/network/types/connectionPorts.ts +++ b/apps/server/src/network/types/connectionPorts.ts @@ -18,6 +18,7 @@ FindRoomByPlayerPort, JoinRoomPort, RoomPhaseTransitionPort, + UpdateLobbySettingsPort, } from "@server/domains/room/application/ports/roomUseCasePorts"; /** 接続時のルーム処理で利用する入力ポート集合 */ @@ -48,6 +49,11 @@ /** ルーム参加イベント調停で利用するルーム依存ポート */ export type JoinRoomEventRoomUseCasePort = Pick; +/** ロビー設定更新イベント調停で利用するルーム依存ポート */ +export type LobbySettingsUpdateEventRoomUseCasePort = + & Pick + & UpdateLobbySettingsPort; + /** ルーム参加イベント調停で利用するランタイム依存ポート */ export type JoinRoomEventRuntimeUseCasePort = Pick< ConnectionRuntimePort, @@ -59,7 +65,8 @@ & ConnectionRoomPort & DisconnectRoomPort & FindRoomByIdPort - & DeleteRoomPort; + & DeleteRoomPort + & UpdateLobbySettingsPort; /** ソケット接続全体で利用するランタイム管理ポート集合 */ export type SocketConnectionRuntimePort = diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index 1c82b78..c03ecf2 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -4,6 +4,7 @@ */ import type { domain, + LobbySettingsUpdatePayload, PlaceBombPayload, BombHitReportPayload, StartGameRequestPayload, @@ -102,6 +103,33 @@ ); }; +/** LOBBY_SETTINGS_UPDATEイベントのペイロードがロビー設定情報であるか判定する */ +export const isLobbySettingsUpdatePayload = ( + value: unknown, +): value is LobbySettingsUpdatePayload => { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + const { targetPlayerCount, fieldSizePreset } = candidate; + + const isValidCount = ( + typeof targetPlayerCount === "number" && + Number.isInteger(targetPlayerCount) && + targetPlayerCount > 0 + ); + + const isValidPreset = ( + fieldSizePreset === "SMALL" + || fieldSizePreset === "MEDIUM" + || fieldSizePreset === "LARGE" + || fieldSizePreset === "XLARGE" + ); + + return isValidCount && isValidPreset; +}; + /** JOIN_ROOMイベントのペイロードが参加情報であるか判定する */ export const isJoinRoomPayload = ( value: unknown, diff --git a/packages/shared/src/domains/room/room.type.ts b/packages/shared/src/domains/room/room.type.ts index 22d8039..fa2af80 100644 --- a/packages/shared/src/domains/room/room.type.ts +++ b/packages/shared/src/domains/room/room.type.ts @@ -25,6 +25,8 @@ status: RoomPhase; maxPlayers: number; fieldSizePreset: FieldSizePreset; + /** ホストがロビーで選択したゲーム参加人数 */ + targetPlayerCount?: number; } /** ルーム参加時に送信するペイロード */ diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index ac0ca32..3fd765c 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -10,6 +10,7 @@ /** ロビーイベントのペイロード型を再公開する */ export type { JoinRoomPayload, + LobbySettingsUpdatePayload, RoomJoinRejectedPayload, RoomUpdatePayload, } from "./payloads/lobbyPayloads"; diff --git a/packages/shared/src/protocol/maps/lobbyEventPayloadMap.ts b/packages/shared/src/protocol/maps/lobbyEventPayloadMap.ts index 9625774..fcb3873 100644 --- a/packages/shared/src/protocol/maps/lobbyEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/lobbyEventPayloadMap.ts @@ -6,6 +6,7 @@ import { SocketEvents } from "../socketEvents"; import type { JoinRoomPayload, + LobbySettingsUpdatePayload, RoomJoinRejectedPayload, RoomUpdatePayload, } from "../payloads/lobbyPayloads"; @@ -13,6 +14,7 @@ /** ロビー関連のクライアント送信イベントペイロード対応表 */ export type LobbyClientToServerEventPayloadMap = { [SocketEvents.JOIN_ROOM]: JoinRoomPayload; + [SocketEvents.LOBBY_SETTINGS_UPDATE]: LobbySettingsUpdatePayload; }; /** ロビー関連のサーバー送信イベントペイロード対応表 */ diff --git a/packages/shared/src/protocol/payloads/lobbyPayloads.ts b/packages/shared/src/protocol/payloads/lobbyPayloads.ts index 3f5cdcb..171984e 100644 --- a/packages/shared/src/protocol/payloads/lobbyPayloads.ts +++ b/packages/shared/src/protocol/payloads/lobbyPayloads.ts @@ -13,3 +13,9 @@ /** ROOM_UPDATE イベントで送受信するルーム状態情報 */ export type RoomUpdatePayload = roomTypes.Room; + +/** LOBBY_SETTINGS_UPDATE イベントでホストが送信するロビー設定情報 */ +export type LobbySettingsUpdatePayload = { + targetPlayerCount: number; + fieldSizePreset: roomTypes.Room["fieldSizePreset"]; +}; diff --git a/packages/shared/src/protocol/socketEvents.ts b/packages/shared/src/protocol/socketEvents.ts index 242ae61..85fe404 100644 --- a/packages/shared/src/protocol/socketEvents.ts +++ b/packages/shared/src/protocol/socketEvents.ts @@ -14,6 +14,7 @@ JOIN_ROOM: "join-room", ROOM_JOIN_REJECTED: "room-join-rejected", ROOM_UPDATE: "room-update", + LOBBY_SETTINGS_UPDATE: "lobby-settings-update", START_GAME: "start-game", GAME_START: "game-start", READY_FOR_GAME: "ready-for-game",