diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index 49ff35c..3b7e8a9 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -7,6 +7,8 @@ import { contracts as protocol } from "@repo/shared"; import type { LobbySettingsUpdatePayload, + SelectTeamPayload, + SelectTeamRejectedPayload, ServerToClientPayloadOf, StartGameRequestPayload, } from "@repo/shared"; @@ -31,6 +33,9 @@ ) => void; startGame: (payload?: StartGameRequestPayload) => void; updateLobbySettings: (payload: LobbySettingsUpdatePayload) => void; + selectTeam: (payload: SelectTeamPayload) => void; + onSelectTeamRejected: (callback: (payload: SelectTeamRejectedPayload) => void) => void; + offSelectTeamRejected: (callback: (payload: SelectTeamRejectedPayload) => void) => void; }; /** ロビー画面向けのソケットハンドラを生成する */ @@ -54,6 +59,15 @@ updateLobbySettings: (payload) => { emitEvent(protocol.SocketEvents.LOBBY_SETTINGS_UPDATE, payload); }, + selectTeam: (payload) => { + emitEvent(protocol.SocketEvents.SELECT_TEAM, payload); + }, + onSelectTeamRejected: (callback) => { + onEvent(protocol.SocketEvents.SELECT_TEAM_REJECTED, callback); + }, + offSelectTeamRejected: (callback) => { + offEvent(protocol.SocketEvents.SELECT_TEAM_REJECTED, callback); + }, }; }; diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx index 8b22573..07e67b9 100644 --- a/apps/client/src/scenes/lobby/LobbyScene.tsx +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { domain } from "@repo/shared"; -import type { FieldSizePreset, StartGameRequestPayload } from "@repo/shared"; +import type { FieldSizePreset, StartGameRequestPayload, TeamAssignmentMode } 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 { GearIcon } from "./components/GearIcon"; import { LobbyRuleModal } from "./components/LobbyRuleModal"; +import { LobbySettingsModal, toFieldPresetLabel } from "./components/LobbySettingsModal"; import { LobbyStartConfirmModal } from "./components/LobbyStartConfirmModal"; import { LOBBY_BACK_BUTTON_STYLE, @@ -21,11 +23,71 @@ LOBBY_PLAYER_LIST_ITEM_STYLE, LOBBY_PLAYER_LIST_PANEL_STYLE, LOBBY_SELECT_STYLE, + LOBBY_SETTINGS_GEAR_BUTTON_STYLE, LOBBY_START_BUTTON_STYLE, LOBBY_TITLE_STYLE, LOBBY_WAITING_STYLE, } from "./styles/LobbyScene.styles"; +/** ホスト側で管理するゲーム設定 */ +export type LobbyGameSettings = { + targetPlayerCount: number; + fieldSizePreset: FieldSizePreset; + teamAssignmentMode: TeamAssignmentMode; +}; + +const TEAM_COLOR_LABELS = ["赤チーム", "青チーム", "緑チーム", "黄チーム"] as const; + +/** チームIDを色名ラベルに変換する */ +const toTeamLabel = (teamId: number): string => TEAM_COLOR_LABELS[teamId] ?? `チーム${teamId + 1}`; + +/** チーム選択のオプション(チームID 0〜3 + ランダム) */ +const TEAM_SELECT_OPTIONS: Array<{ value: number | null; label: string }> = [ + { value: null, label: "ランダム" }, + { value: 0, label: toTeamLabel(0) }, + { value: 1, label: toTeamLabel(1) }, + { value: 2, label: toTeamLabel(2) }, + { value: 3, label: toTeamLabel(3) }, +]; + +type TeamSelectFieldProps = { + id: string; + disabled: boolean; + value: string; + onChange: (preferredTeamId: number | null) => void; + teamFullMessage: string | null; +}; + +/** チーム選択プルダウン(ランダムモード時はdisabled) */ +const TeamSelectField = ({ id, disabled, value, onChange, teamFullMessage }: TeamSelectFieldProps) => ( +
+ + + {teamFullMessage && ( +
+ {teamFullMessage} +
+ )} +
+); + type Props = { room: domain.room.Room | null; myId: string | null; @@ -66,29 +128,22 @@ return options; }, [minimumStartPlayerCount, maxStartPlayerCount]); - const [selectedStartPlayerCount, setSelectedStartPlayerCount] = useState( - minimumStartPlayerCount, - ); - const fieldPresetOptions = useMemo(() => { - return Object.keys( - config.GAME_CONFIG.FIELD_PRESETS, - ) as FieldSizePreset[]; - }, []); - const [selectedFieldSizePreset, setSelectedFieldSizePreset] = - useState(config.GAME_CONFIG.DEFAULT_FIELD_PRESET); + const [gameSettings, setGameSettings] = useState({ + targetPlayerCount: minimumStartPlayerCount, + fieldSizePreset: config.GAME_CONFIG.DEFAULT_FIELD_PRESET, + teamAssignmentMode: "random", + }); + const [teamFullMessage, setTeamFullMessage] = useState(null); const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); const [isStartConfirmVisible, setIsStartConfirmVisible] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); useEffect(() => { - setSelectedStartPlayerCount((prev) => { - if (prev < minimumStartPlayerCount || prev > maxStartPlayerCount) { - return minimumStartPlayerCount; + setGameSettings((prev) => { + const count = prev.targetPlayerCount; + if (count < minimumStartPlayerCount || count > maxStartPlayerCount || count % teamUnit !== 0) { + return { ...prev, targetPlayerCount: minimumStartPlayerCount }; } - - if (prev % teamUnit !== 0) { - return minimumStartPlayerCount; - } - return prev; }); }, [minimumStartPlayerCount, maxStartPlayerCount]); @@ -100,10 +155,11 @@ } socketManager.lobby.updateLobbySettings({ - targetPlayerCount: selectedStartPlayerCount, - fieldSizePreset: selectedFieldSizePreset, + targetPlayerCount: gameSettings.targetPlayerCount, + fieldSizePreset: gameSettings.fieldSizePreset, + teamAssignmentMode: gameSettings.teamAssignmentMode, }); - }, [isMeOwner, selectedStartPlayerCount, selectedFieldSizePreset]); + }, [isMeOwner, gameSettings.targetPlayerCount, gameSettings.fieldSizePreset, gameSettings.teamAssignmentMode]); const handleStartClick = () => { setIsStartConfirmVisible(true); @@ -112,8 +168,8 @@ const handleStartConfirm = () => { setIsStartConfirmVisible(false); onStart({ - targetPlayerCount: selectedStartPlayerCount, - fieldSizePreset: selectedFieldSizePreset, + targetPlayerCount: gameSettings.targetPlayerCount, + fieldSizePreset: gameSettings.fieldSizePreset, }); }; @@ -121,20 +177,26 @@ setIsStartConfirmVisible(false); }; - const toFieldPresetLabel = (preset: FieldSizePreset): string => { - const range = config.GAME_CONFIG.FIELD_PRESETS[preset].recommendedPlayers; - const baseLabel = - preset === "SMALL" - ? "小" - : preset === "MEDIUM" - ? "中" - : preset === "LARGE" - ? "大" - : "極大"; + useEffect(() => { + const handler = () => { + setTeamFullMessage("このチームは満員です。別のチームを選んでください。"); + setTimeout(() => { setTeamFullMessage(null); }, 3000); + }; + socketManager.lobby.onSelectTeamRejected(handler); + return () => { socketManager.lobby.offSelectTeamRejected(handler); }; + }, []); - return `${baseLabel} (${range.min}-${range.max}人目安)`; + const handleSelectTeam = (preferredTeamId: number | null) => { + socketManager.lobby.selectTeam({ preferredTeamId }); }; + // 自分のチーム選択状態を取得する + const myPreferredTeamId = room.players.find((p) => p.id === myId)?.preferredTeamId ?? null; + + // チーム割り当て方式のラベルを取得する + const teamAssignmentModeLabel = + room.teamAssignmentMode === "player_select" ? "プレイヤーが選択" : "ランダム"; + return ( <>