diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index da5687c..63f5e75 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -45,8 +45,8 @@ - socketManager.lobby.startGame(targetPlayerCount) + onStart={(payload) => + socketManager.lobby.startGame(payload) } onBackToTitle={() => returnToTitle({ leaveRoom: true })} /> diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index 3a98026..d131dba 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -1,4 +1,40 @@ import { config as sharedConfig } from "@repo/shared"; +import type { FieldSizePreset, GameStartPayload } from "@repo/shared"; + +const runtimeMapSize = { + gridCols: sharedConfig.GAME_CONFIG.GRID_COLS, + gridRows: sharedConfig.GAME_CONFIG.GRID_ROWS, +}; + +/** フィールドサイズ種別からクライアント実行中のマップサイズを更新する */ +export const setRuntimeMapSizeByPreset = (preset: FieldSizePreset): void => { + const resolved = sharedConfig.resolveFieldGridSize(preset); + runtimeMapSize.gridCols = resolved.cols; + runtimeMapSize.gridRows = resolved.rows; +}; + +/** GAME_STARTペイロードを優先してクライアント実行中のマップサイズを更新する */ +export const applyRuntimeMapSizeFromGameStart = ( + payload: Pick, +): void => { + if ( + Number.isFinite(payload.gridCols) + && Number.isFinite(payload.gridRows) + && payload.gridCols > 0 + && payload.gridRows > 0 + ) { + runtimeMapSize.gridCols = payload.gridCols; + runtimeMapSize.gridRows = payload.gridRows; + return; + } + + setRuntimeMapSizeByPreset(payload.fieldSizePreset); +}; + +/** クライアント実行中マップサイズを既定値へ戻す */ +export const resetRuntimeMapSizeToDefault = (): void => { + setRuntimeMapSizeByPreset(sharedConfig.GAME_CONFIG.DEFAULT_FIELD_PRESET); +}; const sharedBombRenderScale = (sharedConfig.GAME_CONFIG as { BOMB_RENDER_SCALE?: number }) @@ -66,6 +102,12 @@ const GAME_CONFIG = { ...sharedConfig.GAME_CONFIG, ...CLIENT_GAME_CONFIG, + get GRID_COLS(): number { + return runtimeMapSize.gridCols; + }, + get GRID_ROWS(): number { + return runtimeMapSize.gridRows; + }, get MAP_WIDTH_PX(): number { return this.GRID_COLS * this.GRID_CELL_SIZE; }, diff --git a/apps/client/src/hooks/useSocketSubscriptions.ts b/apps/client/src/hooks/useSocketSubscriptions.ts index 3af4fa3..25b53a7 100644 --- a/apps/client/src/hooks/useSocketSubscriptions.ts +++ b/apps/client/src/hooks/useSocketSubscriptions.ts @@ -5,8 +5,12 @@ */ import { useEffect } from "react"; import { socketManager } from "@client/network/SocketManager"; +import { + applyRuntimeMapSizeFromGameStart, + setRuntimeMapSizeByPreset, +} from "@client/config"; import { domain } from "@repo/shared"; -import type { GameResultPayload } from "@repo/shared"; +import type { GameResultPayload, GameStartPayload } from "@repo/shared"; import type { AppFlowAction } from "./types/appFlowState"; type UseSocketSubscriptionsParams = { @@ -18,7 +22,7 @@ type AppSocketHandlers = { handleConnect: (id: string) => void; handleRoomUpdate: (updatedRoom: domain.room.Room) => void; - handleGameStart: () => void; + handleGameStart: (payload: GameStartPayload) => void; handleGameResult: (payload: GameResultPayload) => void; }; @@ -76,6 +80,7 @@ handleRoomUpdate: (updatedRoom: domain.room.Room) => { completeJoinRequest(); + setRuntimeMapSizeByPreset(updatedRoom.fieldSizePreset); if ( scenePhase === domain.app.ScenePhase.PLAYING || scenePhase === domain.app.ScenePhase.RESULT @@ -86,7 +91,8 @@ } }, - handleGameStart: () => { + handleGameStart: (payload) => { + applyRuntimeMapSizeFromGameStart(payload); dispatchAppFlow({ type: "setPlaying" }); }, diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts index 1b1d70e..00c037b 100644 --- a/apps/client/src/network/handlers/LobbyHandler.ts +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -5,7 +5,7 @@ */ import type { Socket } from "socket.io-client"; import { contracts as protocol } from "@repo/shared"; -import type { ServerToClientPayloadOf } from "@repo/shared"; +import type { ServerToClientPayloadOf, StartGameRequestPayload } from "@repo/shared"; import { createClientSocketEventBridge } from "./socketEventBridge"; /** ロビー画面で利用する通信操作の契約 */ @@ -25,7 +25,7 @@ room: ServerToClientPayloadOf, ) => void, ) => void; - startGame: (targetPlayerCount?: number) => void; + startGame: (payload?: StartGameRequestPayload) => void; }; /** ロビー画面向けのソケットハンドラを生成する */ @@ -43,10 +43,8 @@ offRoomUpdate: (callback) => { offEvent(protocol.SocketEvents.ROOM_UPDATE, callback); }, - startGame: (targetPlayerCount) => { - emitEvent(protocol.SocketEvents.START_GAME, { - targetPlayerCount, - }); + startGame: (payload) => { + emitEvent(protocol.SocketEvents.START_GAME, payload ?? {}); }, }; }; diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index 0f4759e..2aef873 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -63,7 +63,10 @@ me.tick(); const now = this.nowMsProvider(); - if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { + if ( + now - this.lastPositionSentTime + >= config.GAME_CONFIG.NETWORK_SYNC.PLAYER_POSITION_UPDATE_MS + ) { const position = me.getPosition(); this.moveSender.sendMove(position.x, position.y); this.lastPositionSentTime = now; @@ -71,7 +74,7 @@ } else if (this.wasMoving) { me.tick(); const position = me.getPosition(); - this.moveSender.sendMove(position.x, position.y); + this.moveSender.sendMove(position.x, position.y, { force: true }); } else { me.tick(); } diff --git a/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts b/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts index 9efa0c6..64fee02 100644 --- a/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts +++ b/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts @@ -3,17 +3,38 @@ * ローカルプレイヤー移動の送信責務を提供する * シミュレーション層から通信実装を分離する */ +import { domain } from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; +/** 移動送信時の動作オプション */ +export type SendMoveOptions = { + force?: boolean; +}; + /** 移動送信のインターフェース型 */ export type MoveSender = { - sendMove: (x: number, y: number) => void; + sendMove: (x: number, y: number, options?: SendMoveOptions) => void; }; /** ソケット経由で移動送信を行う実装 */ export class SocketPlayerMoveSender implements MoveSender { + private lastSentPosition: domain.game.player.MovePayload | null = null; + /** 指定座標をサーバーへ送信する */ - public sendMove(x: number, y: number): void { - socketManager.game.sendMove(x, y); + public sendMove(x: number, y: number, options?: SendMoveOptions): void { + const quantizedPosition = domain.game.player.quantizeMovePayload({ x, y }); + const lastPosition = this.lastSentPosition; + + if ( + !options?.force + && + lastPosition + && domain.game.player.isSameMovePayload(lastPosition, quantizedPosition) + ) { + return; + } + + this.lastSentPosition = quantizedPosition; + socketManager.game.sendMove(quantizedPosition.x, quantizedPosition.y); } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts index cc68f75..887ea68 100644 --- a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts @@ -28,11 +28,7 @@ /** ハリケーン状態を描画へ同期する */ public applyUpdates(states: UpdateHurricanesPayload): void { - const activeIds = new Set(); - states.forEach((state) => { - activeIds.add(state.id); - let target = this.displayById.get(state.id); if (!target) { const created = this.createDisplay(); @@ -50,16 +46,6 @@ target.container.y = state.y * config.GAME_CONFIG.GRID_CELL_SIZE; target.container.rotation = state.rotationRad; }); - - this.displayById.forEach((display, id) => { - if (activeIds.has(id)) { - return; - } - - this.layer.removeChild(display.container); - display.container.destroy({ children: true }); - this.displayById.delete(id); - }); } /** 描画リソースを破棄する */ diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index aabba96..9d57079 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -19,9 +19,12 @@ const DEFAULT_TEAM_PAINT_RATES = new Array( config.GAME_CONFIG.TEAM_COUNT, ).fill(0); -const DEFAULT_MINIMAP_TEAM_IDS = new Array( - config.GAME_CONFIG.GRID_COLS * config.GAME_CONFIG.GRID_ROWS, -).fill(-1); + +const createDefaultMiniMapTeamIds = (): number[] => { + return new Array( + config.GAME_CONFIG.GRID_COLS * config.GAME_CONFIG.GRID_ROWS, + ).fill(-1); +}; type SceneControllerState = { timeLeft: string; @@ -38,14 +41,16 @@ | { type: "syncMiniMap"; payload: MiniMapState } | { type: "reset" }; -const INITIAL_SCENE_CONTROLLER_STATE: SceneControllerState = { - timeLeft: getInitialTimeDisplay(), - startCountdownText: null, - isInputEnabled: false, - teamPaintRates: DEFAULT_TEAM_PAINT_RATES, - miniMapTeamIds: DEFAULT_MINIMAP_TEAM_IDS, - localBombHitCount: 0, - localPlayerPosition: null, +const createInitialSceneControllerState = (): SceneControllerState => { + return { + timeLeft: getInitialTimeDisplay(), + startCountdownText: null, + isInputEnabled: false, + teamPaintRates: DEFAULT_TEAM_PAINT_RATES, + miniMapTeamIds: createDefaultMiniMapTeamIds(), + localBombHitCount: 0, + localPlayerPosition: null, + }; }; const sceneControllerReducer = ( @@ -73,7 +78,7 @@ }; } case "reset": { - return INITIAL_SCENE_CONTROLLER_STATE; + return createInitialSceneControllerState(); } default: { return state; @@ -87,7 +92,7 @@ const gameManagerRef = useRef(null); const [state, dispatch] = useReducer( sceneControllerReducer, - INITIAL_SCENE_CONTROLLER_STATE, + createInitialSceneControllerState(), ); useEffect(() => { diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx index af8cc78..ae26629 100644 --- a/apps/client/src/scenes/lobby/LobbyScene.tsx +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from "react"; import { domain } from "@repo/shared"; +import type { FieldSizePreset, StartGameRequestPayload } from "@repo/shared"; +import { config } from "@client/config"; import { OVERLAY_BUTTON_STYLE } from "@client/scenes/shared/styles/overlayStyles"; import { LobbyRuleModal } from "./components/LobbyRuleModal"; type Props = { room: domain.room.Room | null; myId: string | null; - onStart: (targetPlayerCount: number) => void; + onStart: (payload: StartGameRequestPayload) => void; onBackToTitle: () => void; }; @@ -46,6 +48,13 @@ 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 [isRuleModalOpen, setIsRuleModalOpen] = useState(false); useEffect(() => { @@ -63,7 +72,24 @@ }, [minimumStartPlayerCount, maxStartPlayerCount]); const handleStart = () => { - onStart(selectedStartPlayerCount); + onStart({ + targetPlayerCount: selectedStartPlayerCount, + fieldSizePreset: selectedFieldSizePreset, + }); + }; + + const toFieldPresetLabel = (preset: FieldSizePreset): string => { + const range = config.GAME_CONFIG.FIELD_PRESETS[preset].recommendedPlayers; + const baseLabel = + preset === "SMALL" + ? "小" + : preset === "MEDIUM" + ? "中" + : preset === "LARGE" + ? "大" + : "極大"; + + return `${baseLabel} (${range.min}-${range.max}人目安)`; }; return ( @@ -226,6 +252,40 @@ ))} + + +