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 df0c394..64fee02 100644 --- a/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts +++ b/apps/client/src/scenes/game/application/network/PlayerMoveSender.ts @@ -3,32 +3,33 @@ * ローカルプレイヤー移動の送信責務を提供する * シミュレーション層から通信実装を分離する */ -import { config } from "@client/config"; +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; -}; - -type QuantizedPosition = { - x: number; - y: number; + sendMove: (x: number, y: number, options?: SendMoveOptions) => void; }; /** ソケット経由で移動送信を行う実装 */ export class SocketPlayerMoveSender implements MoveSender { - private lastSentPosition: QuantizedPosition | null = null; + private lastSentPosition: domain.game.player.MovePayload | null = null; /** 指定座標をサーバーへ送信する */ - public sendMove(x: number, y: number): void { - const quantizedPosition = this.quantizePosition(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 - && lastPosition.x === quantizedPosition.x - && lastPosition.y === quantizedPosition.y + && domain.game.player.isSameMovePayload(lastPosition, quantizedPosition) ) { return; } @@ -36,20 +37,4 @@ this.lastSentPosition = quantizedPosition; socketManager.game.sendMove(quantizedPosition.x, quantizedPosition.y); } - - private quantizePosition(x: number, y: number): QuantizedPosition { - return { - x: this.quantizeAxis(x), - y: this.quantizeAxis(y), - }; - } - - private quantizeAxis(value: number): number { - if (!Number.isFinite(value)) { - return 0; - } - - const scale = config.GAME_CONFIG.POSITION_QUANTIZE_SCALE; - return Math.round(value * scale) / scale; - } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index f6e826c..76ba85c 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -85,7 +85,7 @@ return; } - const tickRate = config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS; + const tickRate = config.GAME_CONFIG.NETWORK_SYNC.PLAYER_POSITION_UPDATE_MS; const session = new GameRoomSession( this.roomId, playerIds, diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index ed6fa07..b0a9b7d 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -25,6 +25,34 @@ output: StartGameOutputPort; }; +type TickUpdatePublishParams = { + roomId: string; + output: StartGameOutputPort; + tickData: Parameters[2] extends { + onTick: (data: infer TTickData) => void; + } + ? TTickData + : never; +}; + +const publishTickUpdates = ({ + roomId, + output, + tickData, +}: TickUpdatePublishParams): void => { + if (tickData.playerUpdates.length > 0) { + output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); + } + + if (tickData.cellUpdates.length > 0) { + output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); + } + + if (tickData.hurricaneUpdates.length > 0) { + output.publishUpdateHurricanesToRoom(roomId, tickData.hurricaneUpdates); + } +}; + /** ゲームセッション開始とティック通知,終了通知を実行する */ export const startGameUseCase = ({ roomId, @@ -50,17 +78,7 @@ gameSession.startRoomSession(playerIds, playerNamesById, { onTick: (tickData) => { - if (tickData.playerUpdates.length > 0) { - output.publishUpdatePlayersToRoom(roomId, tickData.playerUpdates); - } - - if (tickData.cellUpdates.length > 0) { - output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); - } - - if (tickData.hurricaneUpdates.length > 0) { - output.publishUpdateHurricanesToRoom(roomId, tickData.hurricaneUpdates); - } + publishTickUpdates({ roomId, output, tickData }); }, onGameEnd: (resultPayload) => { logEvent(logScopes.GAME_USE_CASE, { diff --git a/apps/server/src/network/adapters/gamePayloadSanitizers.ts b/apps/server/src/network/adapters/gamePayloadSanitizers.ts index 35fffec..2c2ba1f 100644 --- a/apps/server/src/network/adapters/gamePayloadSanitizers.ts +++ b/apps/server/src/network/adapters/gamePayloadSanitizers.ts @@ -2,11 +2,19 @@ * gamePayloadSanitizers * ゲーム関連の送信ペイロードを境界で正規化する */ +import { domain } from "@repo/shared"; import type { UpdatePlayersPayload } from "@repo/shared"; /** UPDATE_PLAYERS の送信値を座標差分のみへ正規化する */ export const sanitizeUpdatePlayersPayload = ( players: UpdatePlayersPayload ): UpdatePlayersPayload => { - return players.map(({ id, x, y }) => ({ id, x, y })); + return players.map(({ id, x, y }) => { + const quantized = domain.game.player.quantizeMovePayload({ x, y }); + return { + id, + x: quantized.x, + y: quantized.y, + }; + }); }; diff --git a/apps/server/src/network/handlers/CommonHandler.ts b/apps/server/src/network/handlers/CommonHandler.ts index 493e951..2636c34 100644 --- a/apps/server/src/network/handlers/CommonHandler.ts +++ b/apps/server/src/network/handlers/CommonHandler.ts @@ -12,27 +12,45 @@ createEmitToSocketById, } from "@server/network/adapters/socketEmitters"; -/** ハンドラで共通利用する送信コンテキスト */ -export type CommonHandlerContext = { +/** 到達保証を重視する送信関数群 */ +export type ReliableEmitters = { emitToAll: ReturnType; emitToRoom: ReturnType; - emitToRoomVolatile: ReturnType; emitToRoomExceptSocket: ReturnType; emitToSocket: ReturnType; emitToSocketById: ReturnType; }; +/** 鮮度を重視する高頻度送信関数群 */ +export type RealtimeEmitters = { + emitToRoom: ReturnType; +}; + +/** ハンドラで共通利用する送信コンテキスト */ +export type CommonHandlerContext = { + reliable: ReliableEmitters; + realtime: RealtimeEmitters; +}; + /** 送信先別のエミッタをまとめた共通コンテキストを生成する */ export const createCommonHandlerContext = ( io: Server, socket: Socket ): CommonHandlerContext => { - return { + const reliable: ReliableEmitters = { emitToAll: createEmitToAll(io), emitToRoom: createEmitToRoom(io), - emitToRoomVolatile: createEmitToRoomVolatile(io), emitToRoomExceptSocket: createEmitToRoomExceptSocket(io), emitToSocket: createEmitToSocket(socket), emitToSocketById: createEmitToSocketById(io), }; + + const realtime: RealtimeEmitters = { + emitToRoom: createEmitToRoomVolatile(io), + }; + + return { + reliable, + realtime, + }; }; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 0fb931b..3173cc0 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -48,16 +48,18 @@ export const createGameOutputAdapter = ( common: CommonHandlerContext, ): GameOutputAdapter => { + const { reliable, realtime } = common; + return { publishPongToSocket: (payload: PongPayload) => { - common.emitToSocket(protocol.SocketEvents.PONG, payload); + reliable.emitToSocket(protocol.SocketEvents.PONG, payload); }, publishUpdatePlayersToRoom: ( roomId: RoomId, players: UpdatePlayersPayload, ) => { const sanitizedPlayers = sanitizeUpdatePlayersPayload(players); - common.emitToRoomVolatile( + realtime.emitToRoom( roomId, protocol.SocketEvents.UPDATE_PLAYERS, sanitizedPlayers, @@ -68,7 +70,7 @@ cellUpdates: domainNs.game.gridMap.CellUpdate[], ) => { const grouped = domainNs.game.gridMap.groupCellUpdates(cellUpdates); - common.emitToRoom( + reliable.emitToRoom( roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, grouped, @@ -78,26 +80,26 @@ roomId: RoomId, hurricanes: UpdateHurricanesPayload, ) => { - common.emitToRoomVolatile( + realtime.emitToRoom( roomId, protocol.SocketEvents.UPDATE_HURRICANES, hurricanes, ); }, publishGameEndToRoom: (roomId: RoomId) => { - common.emitToRoom(roomId, protocol.SocketEvents.GAME_END); + reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); + reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); + reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { - common.emitToSocket(protocol.SocketEvents.CURRENT_PLAYERS, players); + reliable.emitToSocket(protocol.SocketEvents.CURRENT_PLAYERS, players); }, publishGameStartToSocket: (payload: GameStartPayload) => { - common.emitToSocket(protocol.SocketEvents.GAME_START, payload); + reliable.emitToSocket(protocol.SocketEvents.GAME_START, payload); }, publishBombPlacedToOthersInRoom: ( roomId: RoomId, @@ -105,11 +107,11 @@ payload: BombPlacedPayload, ) => { if (isBotPlayerId(ownerSocketId)) { - common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + reliable.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); return; } - common.emitToRoomExceptSocket( + reliable.emitToRoomExceptSocket( roomId, ownerSocketId, protocol.SocketEvents.BOMB_PLACED, @@ -120,7 +122,7 @@ socketId: string, payload: BombPlacedAckPayload, ) => { - common.emitToSocketById( + reliable.emitToSocketById( socketId, protocol.SocketEvents.BOMB_PLACED_ACK, payload, @@ -131,7 +133,7 @@ deadPlayerId: string, payload: PlayerHitPayload, ) => { - common.emitToRoomExceptSocket( + reliable.emitToRoomExceptSocket( roomId, deadPlayerId, protocol.SocketEvents.PLAYER_HIT, @@ -139,13 +141,13 @@ ); }, publishPlayerHitToRoom: (roomId: RoomId, payload: PlayerHitPayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.PLAYER_HIT, payload); + reliable.emitToRoom(roomId, protocol.SocketEvents.PLAYER_HIT, payload); }, publishHurricaneHitToRoom: ( roomId: RoomId, payload: HurricaneHitPayload, ) => { - common.emitToRoom(roomId, protocol.SocketEvents.HURRICANE_HIT, payload); + reliable.emitToRoom(roomId, protocol.SocketEvents.HURRICANE_HIT, payload); }, }; }; diff --git a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts index bb6b4ae..faad83c 100644 --- a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts +++ b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts @@ -86,6 +86,7 @@ deps: GameEventOrchestratorDeps, move: domain.game.player.MovePayload, ): void => { + const normalizedMove = domain.game.player.quantizeMovePayload(move); const resolved = runWithRuntimeByPlayerId( deps.roomManager, deps.runtimeRegistry, @@ -94,7 +95,7 @@ movePlayerUseCase({ gameManager, playerId: deps.socketId, - move, + move: normalizedMove, }); }, ); diff --git a/apps/server/src/network/handlers/room/createRoomOutputAdapter.ts b/apps/server/src/network/handlers/room/createRoomOutputAdapter.ts index 090a0b9..daf7957 100644 --- a/apps/server/src/network/handlers/room/createRoomOutputAdapter.ts +++ b/apps/server/src/network/handlers/room/createRoomOutputAdapter.ts @@ -19,12 +19,14 @@ export const createRoomOutputAdapter = ( common: CommonHandlerContext ): RoomOutputAdapter => { + const { reliable } = common; + return { publishRoomUpdateToRoom: (roomId: RoomId, room: RoomUpdatePayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.ROOM_UPDATE, room); + reliable.emitToRoom(roomId, protocol.SocketEvents.ROOM_UPDATE, room); }, publishJoinRejectedToSocket: (payload: domain.room.JoinRoomRejectedPayload) => { - common.emitToSocket(protocol.SocketEvents.ROOM_JOIN_REJECTED, payload); + reliable.emitToSocket(protocol.SocketEvents.ROOM_JOIN_REJECTED, payload); }, }; }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index aaa29d8..b16fa5e 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -4,14 +4,21 @@ * クライアントとサーバーで参照する定数群を提供する */ /** ゲーム全体で利用する共有設定値 */ +const NETWORK_SYNC_CONFIG = { + PLAYER_POSITION_UPDATE_MS: 50, + POSITION_QUANTIZE_SCALE: 100, +} as const; + +/** ゲーム全体で利用する共有設定値 */ export const GAME_CONFIG = { // ゲーム進行設定(クライアント/サーバー契約) GAME_DURATION_SEC: 180, // 1ゲームの制限時間(秒) GAME_START_DELAY_MS: 5000, // 開始通知から実際にゲーム進行を開始するまでの待機時間(ms) // ネットワーク同期設定(クライアント/サーバー契約) - PLAYER_POSITION_UPDATE_MS: 50, // 座標送信間隔(20Hz) - POSITION_QUANTIZE_SCALE: 100, // 送信座標の量子化スケール(100なら小数第2位まで保持) + NETWORK_SYNC: NETWORK_SYNC_CONFIG, + PLAYER_POSITION_UPDATE_MS: NETWORK_SYNC_CONFIG.PLAYER_POSITION_UPDATE_MS, // 後方互換のため維持 + POSITION_QUANTIZE_SCALE: NETWORK_SYNC_CONFIG.POSITION_QUANTIZE_SCALE, // 後方互換のため維持 // グリッド(マス)設定(クライアント/サーバー契約) GRID_COLS: 40, // 横のマス数(グリッド単位) diff --git a/packages/shared/src/domains/game/player/index.ts b/packages/shared/src/domains/game/player/index.ts index d302a12..3dc0d4d 100644 --- a/packages/shared/src/domains/game/player/index.ts +++ b/packages/shared/src/domains/game/player/index.ts @@ -6,3 +6,9 @@ /** プレイヤー契約関連の型を再公開する */ export type { PlayerData, MovePayload } from "./player.type"; +/** MOVE ペイロード送信の正規化関数を再公開する */ +export { + DEFAULT_MOVE_QUANTIZE_SCALE, + quantizeMovePayload, + isSameMovePayload, +} from "./moveSync"; diff --git a/packages/shared/src/domains/game/player/moveSync.ts b/packages/shared/src/domains/game/player/moveSync.ts new file mode 100644 index 0000000..24f6c01 --- /dev/null +++ b/packages/shared/src/domains/game/player/moveSync.ts @@ -0,0 +1,41 @@ +/** + * moveSync + * MOVE ペイロードの送信正規化に関する関数を提供する + */ +import { GAME_CONFIG } from "../../../config/gameConfig"; +import type { MovePayload } from "./player.type"; + +/** MOVE ペイロード量子化の既定スケール */ +export const DEFAULT_MOVE_QUANTIZE_SCALE = + GAME_CONFIG.NETWORK_SYNC.POSITION_QUANTIZE_SCALE; + +/** MOVE 座標を量子化して送信用の値へ正規化する */ +export const quantizeMovePayload = ( + move: Readonly, + scale = DEFAULT_MOVE_QUANTIZE_SCALE, +): MovePayload => { + return { + x: quantizeMoveAxis(move.x, scale), + y: quantizeMoveAxis(move.y, scale), + }; +}; + +/** MOVE ペイロードの値が一致するかを判定する */ +export const isSameMovePayload = ( + left: Readonly, + right: Readonly, +): boolean => { + return left.x === right.x && left.y === right.y; +}; + +const quantizeMoveAxis = (value: number, scale: number): number => { + if (!Number.isFinite(value)) { + return 0; + } + + if (!Number.isFinite(scale) || scale <= 0) { + return value; + } + + return Math.round(value * scale) / scale; +}; \ No newline at end of file