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 (
<>