diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index 832b480..37a01a3 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -41,7 +41,9 @@ socketManager.lobby.startGame()} + onStart={(targetPlayerCount) => + socketManager.lobby.startGame(targetPlayerCount) + } onBackToTitle={() => returnToTitle({ leaveRoom: true })} /> ); diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index ca0cb41..6e9c994 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -10,15 +10,28 @@ /** ロビー画面で利用する通信操作の契約 */ type LobbyHandler = { - onRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; - onceRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; - offRoomUpdate: (callback: (room: ServerToClientPayloadOf) => void) => void; - startGame: () => void; + onRoomUpdate: ( + callback: ( + room: ServerToClientPayloadOf, + ) => void, + ) => void; + onceRoomUpdate: ( + callback: ( + room: ServerToClientPayloadOf, + ) => void, + ) => void; + offRoomUpdate: ( + callback: ( + room: ServerToClientPayloadOf, + ) => void, + ) => void; + startGame: (targetPlayerCount?: number) => void; }; /** ロビー画面向けのソケットハンドラを生成する */ export const createLobbyHandler = (socket: Socket): LobbyHandler => { - const { onEvent, onceEvent, offEvent, emitEvent } = createClientSocketEventBridge(socket); + const { onEvent, onceEvent, offEvent, emitEvent } = + createClientSocketEventBridge(socket); return { onRoomUpdate: (callback) => { @@ -30,9 +43,11 @@ offRoomUpdate: (callback) => { offEvent(protocol.SocketEvents.ROOM_UPDATE, callback); }, - startGame: () => { - emitEvent(protocol.SocketEvents.START_GAME); - } + startGame: (targetPlayerCount) => { + emitEvent(protocol.SocketEvents.START_GAME, { + targetPlayerCount, + }); + }, }; }; diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx index 7f353e0..12ae64a 100644 --- a/apps/client/src/scenes/lobby/LobbyScene.tsx +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -1,9 +1,10 @@ +import { useEffect, useMemo, useState } from "react"; import type { roomTypes } from "@repo/shared"; type Props = { room: roomTypes.Room | null; myId: string | null; - onStart: () => void; + onStart: (targetPlayerCount: number) => void; onBackToTitle: () => void; }; @@ -12,6 +13,55 @@ return
読み込み中...
; const isMeOwner = room.ownerId === myId; + const teamUnit = 4; + const minimumStartPlayerCount = useMemo(() => { + return Math.max( + teamUnit, + Math.ceil(room.players.length / teamUnit) * teamUnit, + ); + }, [room.players.length]); + + const maxStartPlayerCount = useMemo(() => { + return Math.max( + minimumStartPlayerCount, + Math.floor(room.maxPlayers / teamUnit) * teamUnit, + ); + }, [minimumStartPlayerCount, room.maxPlayers]); + + const startPlayerCountOptions = useMemo(() => { + const options: number[] = []; + for ( + let count = minimumStartPlayerCount; + count <= maxStartPlayerCount; + count += teamUnit + ) { + options.push(count); + } + + return options; + }, [minimumStartPlayerCount, maxStartPlayerCount]); + + const [selectedStartPlayerCount, setSelectedStartPlayerCount] = useState( + minimumStartPlayerCount, + ); + + useEffect(() => { + setSelectedStartPlayerCount((prev) => { + if (prev < minimumStartPlayerCount || prev > maxStartPlayerCount) { + return minimumStartPlayerCount; + } + + if (prev % teamUnit !== 0) { + return minimumStartPlayerCount; + } + + return prev; + }); + }, [minimumStartPlayerCount, maxStartPlayerCount]); + + const handleStart = () => { + onStart(selectedStartPlayerCount); + }; return ( <> @@ -111,24 +161,67 @@ }} > {isMeOwner ? ( - + + + + + ) : (
{ if (!isBotPlayerId(playerId)) { diff --git a/apps/server/src/domains/game/application/services/BotRosterService.ts b/apps/server/src/domains/game/application/services/BotRosterService.ts index 2c4e953..2073868 100644 --- a/apps/server/src/domains/game/application/services/BotRosterService.ts +++ b/apps/server/src/domains/game/application/services/BotRosterService.ts @@ -22,18 +22,50 @@ return playerId.startsWith(BOT_PLAYER_ID_PREFIX); }; -const getRequiredBotCount = (humanPlayerCount: number): number => { +const getMinimumTotalPlayers = (humanPlayerCount: number): number => { const teamCount = config.GAME_CONFIG.TEAM_COUNT; if (teamCount <= 0) { - return 0; + return humanPlayerCount; } const remainder = humanPlayerCount % teamCount; if (remainder === 0) { - return 0; + return humanPlayerCount; } - return teamCount - remainder; + return humanPlayerCount + (teamCount - remainder); +}; + +const resolveTargetTotalPlayers = ( + humanPlayerCount: number, + requestedPlayerCount: number | undefined, +): number => { + const minimumTotal = getMinimumTotalPlayers(humanPlayerCount); + const teamCount = config.GAME_CONFIG.TEAM_COUNT; + const maxTotal = config.GAME_CONFIG.MAX_PLAYERS_PER_ROOM; + + if ( + requestedPlayerCount === undefined || + teamCount <= 0 || + requestedPlayerCount > maxTotal || + requestedPlayerCount < minimumTotal || + requestedPlayerCount % teamCount !== 0 + ) { + return minimumTotal; + } + + return requestedPlayerCount; +}; + +const getRequiredBotCount = ( + humanPlayerCount: number, + requestedPlayerCount: number | undefined, +): number => { + const totalPlayers = resolveTargetTotalPlayers( + humanPlayerCount, + requestedPlayerCount, + ); + return Math.max(0, totalPlayers - humanPlayerCount); }; /** @@ -42,8 +74,12 @@ export const createBalancedSessionPlayerIds = ( roomId: string, humanPlayerIds: string[], + requestedPlayerCount?: number, ): string[] => { - const requiredBotCount = getRequiredBotCount(humanPlayerIds.length); + const requiredBotCount = getRequiredBotCount( + humanPlayerIds.length, + requestedPlayerCount, + ); if (requiredBotCount === 0) { return [...humanPlayerIds]; } diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index bcc0631..4934d91 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -24,6 +24,7 @@ isMovePayload, isPingPayload, isPlaceBombPayload, + isStartGamePayload, } from "@server/network/validation/socketPayloadValidators"; import { createServerSocketOnBridge } from "@server/network/handlers/socketEventBridge"; import { createPayloadGuard } from "@server/network/handlers/payloadGuard"; @@ -41,8 +42,10 @@ export const registerGameHandlers = ( io: Server, socket: Socket, - roomManager: FindRoomByOwnerPort & FindRoomByPlayerPort & RoomPhaseTransitionPort, - runtimeRegistry: FindGameByRoomPort & FindGameByPlayerPort + roomManager: FindRoomByOwnerPort & + FindRoomByPlayerPort & + RoomPhaseTransitionPort, + runtimeRegistry: FindGameByRoomPort & FindGameByPlayerPort, ) => { const common = createCommonHandlerContext(io, socket); const gameOutputAdapter = createGameOutputAdapter(common); @@ -50,19 +53,19 @@ const { guardOnEvent } = createPayloadGuard(socket.id); const guardPingPayload = guardOnEvent( protocol.SocketEvents.PING, - gamePayloadValidators[protocol.SocketEvents.PING] + gamePayloadValidators[protocol.SocketEvents.PING], ); const guardMovePayload = guardOnEvent( protocol.SocketEvents.MOVE, - gamePayloadValidators[protocol.SocketEvents.MOVE] + gamePayloadValidators[protocol.SocketEvents.MOVE], ); const guardPlaceBombPayload = guardOnEvent( protocol.SocketEvents.PLACE_BOMB, - gamePayloadValidators[protocol.SocketEvents.PLACE_BOMB] + gamePayloadValidators[protocol.SocketEvents.PLACE_BOMB], ); const guardBombHitReportPayload = guardOnEvent( protocol.SocketEvents.BOMB_HIT_REPORT, - gamePayloadValidators[protocol.SocketEvents.BOMB_HIT_REPORT] + gamePayloadValidators[protocol.SocketEvents.BOMB_HIT_REPORT], ); // 遅延計測用のPINGを検証しPONGを返す onEvent(protocol.SocketEvents.PING, (clientTime) => { @@ -77,9 +80,14 @@ }); // オーナー開始要求に応じてゲーム進行ユースケースを起動する - onEvent(protocol.SocketEvents.START_GAME, () => { + onEvent(protocol.SocketEvents.START_GAME, (data) => { + if (!isStartGamePayload(data)) { + return; + } + startGameCoordinator({ ownerId: socket.id, + requestedPlayerCount: data.targetPlayerCount, roomManager, runtimeRegistry, output: gameOutputAdapter, @@ -102,7 +110,11 @@ return; } - const runtime = resolveRuntimeByPlayerId(roomManager, runtimeRegistry, socket.id); + const runtime = resolveRuntimeByPlayerId( + roomManager, + runtimeRegistry, + socket.id, + ); if (!runtime) { return; } @@ -120,7 +132,11 @@ return; } - const runtime = resolveRuntimeByPlayerId(roomManager, runtimeRegistry, socket.id); + const runtime = resolveRuntimeByPlayerId( + roomManager, + runtimeRegistry, + socket.id, + ); if (!runtime) { return; } @@ -143,7 +159,11 @@ return; } - const runtime = resolveRuntimeByPlayerId(roomManager, runtimeRegistry, socket.id); + const runtime = resolveRuntimeByPlayerId( + roomManager, + runtimeRegistry, + socket.id, + ); if (!runtime) { return; } diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index 5449c57..7f05d71 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -2,7 +2,12 @@ * socketPayloadValidators * ソケット受信ペイロードの型ガードを提供する */ -import type { playerTypes, roomTypes, PlaceBombPayload, BombHitReportPayload } from "@repo/shared"; +import type { + playerTypes, + roomTypes, + PlaceBombPayload, + BombHitReportPayload, +} from "@repo/shared"; import type { PingPayload } from "@repo/shared"; import { isPlaceBombPayload as isValidPlaceBombPayload } from "@server/domains/game/entities/bomb/bombPayloadValidation"; @@ -20,7 +25,9 @@ }; /** MOVEイベントのペイロードが移動座標であるか判定する */ -export const isMovePayload = (value: unknown): value is playerTypes.MovePayload => { +export const isMovePayload = ( + value: unknown, +): value is playerTypes.MovePayload => { if (typeof value !== "object" || value === null) { return false; } @@ -30,12 +37,16 @@ }; /** PLACE_BOMBイベントのペイロードが爆弾設置要求であるか判定する */ -export const isPlaceBombPayload = (value: unknown): value is PlaceBombPayload => { +export const isPlaceBombPayload = ( + value: unknown, +): value is PlaceBombPayload => { return isValidPlaceBombPayload(value); }; /** BOMB_HIT_REPORTイベントのペイロードが被弾報告であるか判定する */ -export const isBombHitReportPayload = (value: unknown): value is BombHitReportPayload => { +export const isBombHitReportPayload = ( + value: unknown, +): value is BombHitReportPayload => { if (typeof value !== "object" || value === null) { return false; } @@ -44,12 +55,37 @@ return isNonEmptyString(candidate.bombId); }; -/** JOIN_ROOMイベントのペイロードが参加情報であるか判定する */ -export const isJoinRoomPayload = (value: unknown): value is roomTypes.JoinRoomPayload => { +/** START_GAMEイベントのペイロードが開始要求情報であるか判定する */ +export const isStartGamePayload = ( + value: unknown, +): value is { targetPlayerCount?: number } => { if (typeof value !== "object" || value === null) { return false; } const candidate = value as Record; - return isNonEmptyString(candidate.roomId) && isNonEmptyString(candidate.playerName); + const targetPlayerCount = candidate.targetPlayerCount; + if (targetPlayerCount === undefined) { + return true; + } + + return ( + typeof targetPlayerCount === "number" && + Number.isInteger(targetPlayerCount) && + targetPlayerCount > 0 + ); +}; + +/** JOIN_ROOMイベントのペイロードが参加情報であるか判定する */ +export const isJoinRoomPayload = ( + value: unknown, +): value is roomTypes.JoinRoomPayload => { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + return ( + isNonEmptyString(candidate.roomId) && isNonEmptyString(candidate.playerName) + ); }; diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 0da02b4..471bbf7 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -24,6 +24,7 @@ NewPlayerPayload, RemovePlayerPayload, GameStartPayload, + StartGameRequestPayload, MovePayload, PlaceBombPayload, BombPlacedPayload, diff --git a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts index cc53619..7cc6fc9 100644 --- a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts @@ -11,6 +11,7 @@ CurrentPlayersPayload, GameResultPayload, GameStartPayload, + StartGameRequestPayload, MovePayload, NewPlayerPayload, PlaceBombPayload, @@ -24,7 +25,7 @@ /** ゲーム関連のクライアント送信イベントペイロード対応表 */ export type GameClientToServerEventPayloadMap = { - [SocketEvents.START_GAME]: undefined; + [SocketEvents.START_GAME]: StartGameRequestPayload; [SocketEvents.READY_FOR_GAME]: undefined; [SocketEvents.MOVE]: MovePayload; [SocketEvents.PLACE_BOMB]: PlaceBombPayload; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 4ba4ff6..a8d5999 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -5,7 +5,10 @@ */ import type { PlayerPositionUpdate } from "../../domains/game/game.type"; import type { CellUpdate } from "../../domains/gridMap/gridMap.type"; -import type { MovePayload as PlayerMovePayload, PlayerData } from "../../domains/player/player.type"; +import type { + MovePayload as PlayerMovePayload, + PlayerData, +} from "../../domains/player/player.type"; /** GAME_RESULT イベントで送受信するランキング1行 */ export type GameResultRanking = { @@ -53,6 +56,11 @@ /** GAME_START イベントで送受信するゲーム開始情報 */ export type GameStartPayload = { startTime: number }; +/** START_GAME イベントで受信するゲーム開始要求 */ +export type StartGameRequestPayload = { + targetPlayerCount?: number; +}; + /** MOVE イベントで送受信する移動入力情報 */ export type MovePayload = PlayerMovePayload;