diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index ee73ace..00f4d28 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -41,7 +41,9 @@ private myId: string; private getElapsedMs: ElapsedMsProvider; private bombs = new Map(); + private bombRenderPayloadById = new Map(); private pendingOwnRequestToTempBombId = new Map(); + private pendingTempBombIdToOwnRequest = new Map(); private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; private requestSerial = 0; @@ -74,6 +76,7 @@ const tempBombId = this.createTempBombId(requestId); this.pendingOwnRequestToTempBombId.set(requestId, tempBombId); + this.pendingTempBombIdToOwnRequest.set(tempBombId, requestId); this.upsertBomb(tempBombId, this.toRenderPayload(payload)); this.lastBombPlacedElapsedMs = elapsedMs; return { @@ -87,7 +90,7 @@ if (payload.ownerId === this.myId) { const tempBombId = this.pendingOwnRequestToTempBombId.get(payload.requestId); if (tempBombId) { - this.pendingOwnRequestToTempBombId.delete(payload.requestId); + this.removePendingRequestByRequestId(payload.requestId); if (tempBombId !== payload.bombId) { this.removeBomb(tempBombId); } @@ -99,6 +102,11 @@ /** 描画ペイロードで指定IDの爆弾を追加または更新する */ public upsertBomb(bombId: string, payload: BombRenderPayload): void { + const previousPayload = this.bombRenderPayloadById.get(bombId); + if (previousPayload && this.isSameRenderPayload(previousPayload, payload)) { + return; + } + const current = this.bombs.get(bombId); if (current) { this.worldContainer.removeChild(current.getDisplayObject()); @@ -107,6 +115,7 @@ const bomb = new BombController(payload); this.bombs.set(bombId, bomb); + this.bombRenderPayloadById.set(bombId, payload); this.worldContainer.addChild(bomb.getDisplayObject()); } @@ -118,7 +127,8 @@ this.worldContainer.removeChild(bomb.getDisplayObject()); bomb.destroy(); this.bombs.delete(bombId); - this.cleanupPendingRequestByTempBombId(bombId); + this.bombRenderPayloadById.delete(bombId); + this.removePendingRequestByTempBombId(bombId); } /** 爆弾状態を更新し終了済みを破棄する */ @@ -139,7 +149,16 @@ public destroy(): void { this.bombs.forEach((bomb) => bomb.destroy()); this.bombs.clear(); + this.bombRenderPayloadById.clear(); this.pendingOwnRequestToTempBombId.clear(); + this.pendingTempBombIdToOwnRequest.clear(); + } + + private isSameRenderPayload(a: BombRenderPayload, b: BombRenderPayload): boolean { + return a.x === b.x + && a.y === b.y + && a.explodeAtElapsedMs === b.explodeAtElapsedMs + && a.radiusGrid === b.radiusGrid; } private toRenderPayload(payload: { x: number; y: number; explodeAtElapsedMs: number }): BombRenderPayload { @@ -160,11 +179,23 @@ return `temp:${requestId}`; } - private cleanupPendingRequestByTempBombId(tempBombId: string): void { - this.pendingOwnRequestToTempBombId.forEach((pendingTempBombId, requestId) => { - if (pendingTempBombId === tempBombId) { - this.pendingOwnRequestToTempBombId.delete(requestId); - } - }); + private removePendingRequestByRequestId(requestId: string): void { + const tempBombId = this.pendingOwnRequestToTempBombId.get(requestId); + if (!tempBombId) { + return; + } + + this.pendingOwnRequestToTempBombId.delete(requestId); + this.pendingTempBombIdToOwnRequest.delete(tempBombId); + } + + private removePendingRequestByTempBombId(tempBombId: string): void { + const requestId = this.pendingTempBombIdToOwnRequest.get(tempBombId); + if (!requestId) { + return; + } + + this.pendingTempBombIdToOwnRequest.delete(tempBombId); + this.pendingOwnRequestToTempBombId.delete(requestId); } } \ No newline at end of file diff --git a/apps/server/src/network/handlers/GameHandler.ts b/apps/server/src/network/handlers/GameHandler.ts index 139735f..891f520 100644 --- a/apps/server/src/network/handlers/GameHandler.ts +++ b/apps/server/src/network/handlers/GameHandler.ts @@ -7,4 +7,4 @@ export { registerGameHandlers } from "./game/registerGameHandlers"; /** ルーム終了時の爆弾状態掃除関数を外部参照向けに再公開 */ -export { clearBombRoomState } from "./game/registerGameHandlers"; +export { clearBombRoomState } from "./game/bombRoomStateStore"; diff --git a/apps/server/src/network/handlers/game/bombRoomStateStore.ts b/apps/server/src/network/handlers/game/bombRoomStateStore.ts new file mode 100644 index 0000000..53a96e1 --- /dev/null +++ b/apps/server/src/network/handlers/game/bombRoomStateStore.ts @@ -0,0 +1,62 @@ +/** + * bombRoomStateStore + * ルーム単位の爆弾重複排除状態と採番状態を管理する + */ +import { config } from "@repo/shared"; + +type BombRoomStateClearReason = "game-ended" | "room-deleted"; + +const roomBombDedupTable = new Map>(); +const roomBombSerialTable = new Map(); +const isBombRoomStateDebugEnabled = process.env.NODE_ENV !== "production"; + +const cleanupExpiredBombDedup = (roomId: string, nowMs: number) => { + const roomTable = roomBombDedupTable.get(roomId); + if (!roomTable) return; + + roomTable.forEach((expiresAtMs, dedupeKey) => { + if (expiresAtMs <= nowMs) { + roomTable.delete(dedupeKey); + } + }); + + if (roomTable.size === 0) { + roomBombDedupTable.delete(roomId); + } +}; + +/** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ +export const shouldBroadcastBombPlaced = (roomId: string, dedupeKey: string, nowMs: number): boolean => { + cleanupExpiredBombDedup(roomId, nowMs); + + const roomTable = roomBombDedupTable.get(roomId) ?? new Map(); + if (roomTable.has(dedupeKey)) { + return false; + } + + const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; + roomTable.set(dedupeKey, nowMs + ttlMs); + roomBombDedupTable.set(roomId, roomTable); + return true; +}; + +/** ルーム単位の連番からサーバー採番の爆弾IDを生成する */ +export const issueServerBombId = (roomId: string): string => { + const serial = (roomBombSerialTable.get(roomId) ?? 0) + 1; + roomBombSerialTable.set(roomId, serial); + return `${roomId}:${serial}`; +}; + +/** 指定ルームの爆弾採番状態と重複排除状態を破棄する */ +export const clearBombRoomState = (roomId: string, reason: BombRoomStateClearReason): void => { + const hadDedupState = roomBombDedupTable.delete(roomId); + const hadSerialState = roomBombSerialTable.delete(roomId); + + if (!isBombRoomStateDebugEnabled) { + return; + } + + console.debug( + `[BombState] cleared room=${roomId} reason=${reason} dedup=${hadDedupState} serial=${hadSerialState}` + ); +}; diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index 379f0d6..89f3d33 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -3,7 +3,7 @@ * ゲーム関連イベントの受信ハンドラを登録する */ import { Server, Socket } from "socket.io"; -import { config, protocol } from "@repo/shared"; +import { protocol } from "@repo/shared"; import { readyForGameCoordinator } from "@server/application/coordinators/readyForGameCoordinator"; import { startGameCoordinator } from "@server/application/coordinators/startGameCoordinator"; import type { @@ -20,58 +20,7 @@ import { createServerSocketOnBridge } from "@server/network/handlers/socketEventBridge"; import { createPayloadGuard } from "@server/network/handlers/payloadGuard"; import { createGameOutputAdapter } from "./createGameOutputAdapter"; -const roomBombDedupTable = new Map>(); -const roomBombSerialTable = new Map(); -const isBombRoomStateDebugEnabled = process.env.NODE_ENV !== "production"; - -const cleanupExpiredBombDedup = (roomId: string, nowMs: number) => { - const roomTable = roomBombDedupTable.get(roomId); - if (!roomTable) return; - - roomTable.forEach((expiresAtMs, bombId) => { - if (expiresAtMs <= nowMs) { - roomTable.delete(bombId); - } - }); - - if (roomTable.size === 0) { - roomBombDedupTable.delete(roomId); - } -}; - -const shouldBroadcastBombPlaced = (roomId: string, dedupeKey: string, nowMs: number) => { - cleanupExpiredBombDedup(roomId, nowMs); - - const roomTable = roomBombDedupTable.get(roomId) ?? new Map(); - if (roomTable.has(dedupeKey)) { - return false; - } - - const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; - roomTable.set(dedupeKey, nowMs + ttlMs); - roomBombDedupTable.set(roomId, roomTable); - return true; -}; - -const issueServerBombId = (roomId: string): string => { - const serial = (roomBombSerialTable.get(roomId) ?? 0) + 1; - roomBombSerialTable.set(roomId, serial); - return `${roomId}:${serial}`; -}; - -/** 指定ルームの爆弾採番状態と重複排除状態を破棄する */ -export const clearBombRoomState = (roomId: string, reason: "game-ended" | "room-deleted"): void => { - const hadDedupState = roomBombDedupTable.delete(roomId); - const hadSerialState = roomBombSerialTable.delete(roomId); - - if (!isBombRoomStateDebugEnabled) { - return; - } - - console.debug( - `[BombState] cleared room=${roomId} reason=${reason} dedup=${hadDedupState} serial=${hadSerialState}` - ); -}; +import { clearBombRoomState, issueServerBombId, shouldBroadcastBombPlaced } from "./bombRoomStateStore"; /** ゲーム受信イベントごとの入力検証関数を保持するテーブル */ const gamePayloadValidators = {