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/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts index d959001..dd7977b 100644 --- a/apps/server/src/domains/game/loop/HurricaneSystem.ts +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -19,11 +19,52 @@ rotationRad: number; }; +type HurricaneSyncSnapshot = { + x: number; + y: number; + radius: number; + rotationRad: number; +}; + +const HURRICANE_POSITION_SYNC_SCALE = 10; +const HURRICANE_ROTATION_SYNC_SCALE = 4; + +const quantizeValue = (value: number, scale: number): number => { + return Math.round(value * scale) / scale; +}; + +const toHurricaneSyncSnapshot = ( + state: HurricaneState, +): HurricaneSyncSnapshot => { + return { + x: quantizeValue(state.x, HURRICANE_POSITION_SYNC_SCALE), + y: quantizeValue(state.y, HURRICANE_POSITION_SYNC_SCALE), + radius: quantizeValue(state.radius, HURRICANE_POSITION_SYNC_SCALE), + rotationRad: quantizeValue(state.rotationRad, HURRICANE_ROTATION_SYNC_SCALE), + }; +}; + +const isSameHurricaneSyncSnapshot = ( + left: HurricaneSyncSnapshot, + right: HurricaneSyncSnapshot, +): boolean => { + return ( + left.x === right.x + && left.y === right.y + && left.radius === right.radius + && left.rotationRad === right.rotationRad + ); +}; + /** ハリケーン状態の生成更新と被弾判定を管理する */ export class HurricaneSystem { private hasSpawned = false; private hurricanes: HurricaneState[] = []; private readonly lastHitAtMsByPlayerId = new Map(); + private readonly lastSentSnapshotByHurricaneId = new Map< + string, + HurricaneSyncSnapshot + >(); /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ public ensureSpawned(elapsedMs: number): void { @@ -79,13 +120,27 @@ /** 同期配信用のハリケーン状態配列を返す */ public getUpdatePayload(): HurricaneStatePayload[] { - return this.hurricanes.map((hurricane) => ({ - id: hurricane.id, - x: hurricane.x, - y: hurricane.y, - radius: hurricane.radius, - rotationRad: hurricane.rotationRad, - })); + const changedPayloads: HurricaneStatePayload[] = []; + + this.hurricanes.forEach((hurricane) => { + const nextSnapshot = toHurricaneSyncSnapshot(hurricane); + const lastSnapshot = this.lastSentSnapshotByHurricaneId.get(hurricane.id); + + if (lastSnapshot && isSameHurricaneSyncSnapshot(lastSnapshot, nextSnapshot)) { + return; + } + + this.lastSentSnapshotByHurricaneId.set(hurricane.id, nextSnapshot); + changedPayloads.push({ + id: hurricane.id, + x: nextSnapshot.x, + y: nextSnapshot.y, + radius: nextSnapshot.radius, + rotationRad: nextSnapshot.rotationRad, + }); + }); + + return changedPayloads; } /** クールダウン付きで被弾プレイヤーID配列を返す */ @@ -141,6 +196,7 @@ this.hasSpawned = false; this.hurricanes = []; this.lastHitAtMsByPlayerId.clear(); + this.lastSentSnapshotByHurricaneId.clear(); } /** ハリケーン初期状態を生成する */ diff --git a/apps/server/src/network/adapters/gamePayloadSanitizers.ts b/apps/server/src/network/adapters/gamePayloadSanitizers.ts index 2c2ba1f..fbd4adb 100644 --- a/apps/server/src/network/adapters/gamePayloadSanitizers.ts +++ b/apps/server/src/network/adapters/gamePayloadSanitizers.ts @@ -5,6 +5,11 @@ import { domain } from "@repo/shared"; import type { UpdatePlayersPayload } from "@repo/shared"; +type QuantizedPosition = { + x: number; + y: number; +}; + /** UPDATE_PLAYERS の送信値を座標差分のみへ正規化する */ export const sanitizeUpdatePlayersPayload = ( players: UpdatePlayersPayload @@ -18,3 +23,28 @@ }; }); }; + +/** UPDATE_PLAYERS の量子化済み差分から前回送信済みの同値座標を除外する */ +export const filterUnchangedUpdatePlayersPayload = ( + players: UpdatePlayersPayload, + lastSentPositionByPlayerId: Map +): UpdatePlayersPayload => { + const changedPlayers: UpdatePlayersPayload = []; + + players.forEach((player) => { + const lastSentPosition = lastSentPositionByPlayerId.get(player.id); + const nextPosition = { x: player.x, y: player.y }; + + if ( + lastSentPosition + && domain.game.player.isSameMovePayload(lastSentPosition, nextPosition) + ) { + return; + } + + lastSentPositionByPlayerId.set(player.id, nextPosition); + changedPlayers.push(player); + }); + + return changedPlayers; +}; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 3173cc0..4c2dce9 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -24,7 +24,10 @@ GameOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; -import { sanitizeUpdatePlayersPayload } from "@server/network/adapters/gamePayloadSanitizers"; +import { + filterUnchangedUpdatePlayersPayload, + sanitizeUpdatePlayersPayload, +} from "@server/network/adapters/gamePayloadSanitizers"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; @@ -49,6 +52,27 @@ common: CommonHandlerContext, ): GameOutputAdapter => { const { reliable, realtime } = common; + const lastSentPlayerPositionsByRoomId = new Map< + RoomId, + Map + >(); + + const getLastSentPlayerPositions = ( + roomId: RoomId, + ): Map => { + const existing = lastSentPlayerPositionsByRoomId.get(roomId); + if (existing) { + return existing; + } + + const created = new Map(); + lastSentPlayerPositionsByRoomId.set(roomId, created); + return created; + }; + + const clearRoomRealtimeCaches = (roomId: RoomId): void => { + lastSentPlayerPositionsByRoomId.delete(roomId); + }; return { publishPongToSocket: (payload: PongPayload) => { @@ -59,10 +83,19 @@ players: UpdatePlayersPayload, ) => { const sanitizedPlayers = sanitizeUpdatePlayersPayload(players); + const changedPlayers = filterUnchangedUpdatePlayersPayload( + sanitizedPlayers, + getLastSentPlayerPositions(roomId), + ); + + if (changedPlayers.length === 0) { + return; + } + realtime.emitToRoom( roomId, protocol.SocketEvents.UPDATE_PLAYERS, - sanitizedPlayers, + changedPlayers, ); }, publishMapCellUpdatesToRoom: ( @@ -87,12 +120,14 @@ ); }, publishGameEndToRoom: (roomId: RoomId) => { + clearRoomRealtimeCaches(roomId); reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { + clearRoomRealtimeCaches(roomId); reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => {