diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 8005556..979e679 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -12,6 +12,7 @@ GameStartPayload, MovePayload, NewPlayerPayload, + PlaceBombPayload, RemovePlayerPayload, UpdateMapCellsPayload, UpdatePlayersPayload, @@ -40,7 +41,7 @@ onBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; offBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; sendMove: (x: number, y: number) => void; - sendPlaceBomb: (payload: BombPlacedPayload) => void; + sendPlaceBomb: (payload: PlaceBombPayload) => void; readyForGame: () => void; }; diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index c56cc12..c3c61d7 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -4,8 +4,7 @@ * マップ,ネットワーク同期,ゲームループを統合する */ import { Application, Container, Ticker } from "pixi.js"; -import { createBombIdFromPayload } from "@repo/shared"; -import type { BombNetworkPayload } from "@repo/shared"; +import type { BombPlacedPayload } from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; import { GameMapController } from "./entities/map/GameMapController"; import { BombManager } from "./entities/bomb/BombManager"; @@ -44,15 +43,11 @@ if (!placed) return null; socketManager.game.sendPlaceBomb(placed.payload); - return placed.bombId; + return placed.tempBombId; } - public upsertBombFromNetwork(bombId: string, payload: BombNetworkPayload): void { - this.bombManager?.upsertBombFromNetwork(bombId, payload); - } - - public removeBomb(bombId: string): void { - this.bombManager?.removeBomb(bombId); + public applyPlacedBomb(payload: BombPlacedPayload): void { + this.bombManager?.applyPlacedBomb(payload); } // 入力と状態管理 @@ -102,8 +97,7 @@ onGameStart: this.setGameStart.bind(this), onGameEnd: this.lockInput.bind(this), onBombPlacedFromNetwork: (payload) => { - const bombId = createBombIdFromPayload(payload); - this.upsertBombFromNetwork(bombId, payload); + this.applyPlacedBomb(payload); }, }); this.networkSync.bind(); diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 783e3ca..25fc668 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -5,7 +5,7 @@ */ import { Container } from "pixi.js"; import type { - BombNetworkPayload, + BombPlacedPayload, CurrentPlayersPayload, GameStartPayload, NewPlayerPayload, @@ -27,7 +27,7 @@ gameMap: GameMapController; onGameStart: (startTime: number) => void; onGameEnd: () => void; - onBombPlacedFromNetwork: (payload: BombNetworkPayload) => void; + onBombPlacedFromNetwork: (payload: BombPlacedPayload) => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -38,7 +38,7 @@ private gameMap: GameMapController; private onGameStart: (startTime: number) => void; private onGameEnd: () => void; - private onBombPlacedFromNetwork: (payload: BombNetworkPayload) => void; + private onBombPlacedFromNetwork: (payload: BombPlacedPayload) => void; private isBound = false; private debugLog = (message: string) => { @@ -99,7 +99,7 @@ this.onGameEnd(); }; - private handleBombPlaced = (payload: BombNetworkPayload) => { + private handleBombPlaced = (payload: BombPlacedPayload) => { this.onBombPlacedFromNetwork(payload); }; diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index dfd69fb..ee73ace 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -5,8 +5,7 @@ */ import type { Container } from "pixi.js"; import { config } from "@client/config"; -import { createBombIdFromPayload } from "@repo/shared"; -import type { BombNetworkPayload } from "@repo/shared"; +import type { BombPlacedPayload, PlaceBombPayload } from "@repo/shared"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { BombController } from "./BombController"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; @@ -15,14 +14,17 @@ export type ElapsedMsProvider = () => number; /** 爆弾の描画更新に使う入力データ型 */ -export type BombRenderPayload = BombNetworkPayload & { +export type BombRenderPayload = { + x: number; + y: number; + explodeAtElapsedMs: number; radiusGrid: number; }; /** 爆弾設置時に返す結果型 */ export type BombPlacementResult = { - bombId: string; - payload: BombNetworkPayload; + tempBombId: string; + payload: PlaceBombPayload; }; type BombManagerOptions = { @@ -39,7 +41,9 @@ private myId: string; private getElapsedMs: ElapsedMsProvider; private bombs = new Map(); + private pendingOwnRequestToTempBombId = new Map(); private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; + private requestSerial = 0; constructor({ worldContainer, players, myId, getElapsedMs }: BombManagerOptions) { this.worldContainer = worldContainer; @@ -48,7 +52,7 @@ this.getElapsedMs = getElapsedMs; } - /** 自プレイヤー位置に爆弾を設置し,生成IDを返す */ + /** 自プレイヤー位置に爆弾を仮IDで設置し,設置要求を返す */ public placeBomb(): BombPlacementResult | null { const me = this.players[this.myId]; if (!me || !(me instanceof LocalPlayerController)) return null; @@ -60,29 +64,37 @@ } const position = me.getPosition(); - const payload: BombNetworkPayload = { + const requestId = this.createRequestId(elapsedMs); + const payload: PlaceBombPayload = { + requestId, x: position.x, y: position.y, explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, }; - const bombId = createBombIdFromPayload(payload); + const tempBombId = this.createTempBombId(requestId); - this.upsertBombFromNetwork(bombId, payload); + this.pendingOwnRequestToTempBombId.set(requestId, tempBombId); + this.upsertBomb(tempBombId, this.toRenderPayload(payload)); this.lastBombPlacedElapsedMs = elapsedMs; return { - bombId, + tempBombId, payload, }; } - /** 通信ペイロードから指定IDの爆弾を追加または更新する */ - public upsertBombFromNetwork(bombId: string, payload: BombNetworkPayload): void { - const renderPayload: BombRenderPayload = { - ...payload, - radiusGrid: config.GAME_CONFIG.BOMB_RADIUS_GRID, - }; + /** サーバー確定イベントを反映し,必要なら仮IDから正式IDへ置換する */ + public applyPlacedBomb(payload: BombPlacedPayload): void { + if (payload.ownerId === this.myId) { + const tempBombId = this.pendingOwnRequestToTempBombId.get(payload.requestId); + if (tempBombId) { + this.pendingOwnRequestToTempBombId.delete(payload.requestId); + if (tempBombId !== payload.bombId) { + this.removeBomb(tempBombId); + } + } + } - this.upsertBomb(bombId, renderPayload); + this.upsertBomb(payload.bombId, this.toRenderPayload(payload)); } /** 描画ペイロードで指定IDの爆弾を追加または更新する */ @@ -106,6 +118,7 @@ this.worldContainer.removeChild(bomb.getDisplayObject()); bomb.destroy(); this.bombs.delete(bombId); + this.cleanupPendingRequestByTempBombId(bombId); } /** 爆弾状態を更新し終了済みを破棄する */ @@ -126,5 +139,32 @@ public destroy(): void { this.bombs.forEach((bomb) => bomb.destroy()); this.bombs.clear(); + this.pendingOwnRequestToTempBombId.clear(); + } + + private toRenderPayload(payload: { x: number; y: number; explodeAtElapsedMs: number }): BombRenderPayload { + return { + x: payload.x, + y: payload.y, + explodeAtElapsedMs: payload.explodeAtElapsedMs, + radiusGrid: config.GAME_CONFIG.BOMB_RADIUS_GRID, + }; + } + + private createRequestId(elapsedMs: number): string { + this.requestSerial += 1; + return `${this.myId}:${elapsedMs}:${this.requestSerial}`; + } + + private createTempBombId(requestId: string): string { + return `temp:${requestId}`; + } + + private cleanupPendingRequestByTempBombId(tempBombId: string): void { + this.pendingOwnRequestToTempBombId.forEach((pendingTempBombId, requestId) => { + if (pendingTempBombId === tempBombId) { + this.pendingOwnRequestToTempBombId.delete(requestId); + } + }); } } \ No newline at end of file diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index bc551d4..679430a 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, createBombIdFromPayload, protocol } from "@repo/shared"; +import { config, protocol } from "@repo/shared"; import { readyForGameCoordinator } from "@server/application/coordinators/readyForGameCoordinator"; import { startGameCoordinator } from "@server/application/coordinators/startGameCoordinator"; import type { @@ -16,11 +16,12 @@ import { movePlayerUseCase } from "@server/domains/game/application/useCases/movePlayerUseCase"; import { pingUseCase } from "@server/domains/game/application/useCases/pingUseCase"; import { createCommonHandlerContext } from "@server/network/handlers/CommonHandler"; -import { isBombPlacedPayload, isMovePayload, isPingPayload } from "@server/network/validation/socketPayloadValidators"; +import { isMovePayload, isPingPayload, isPlaceBombPayload } from "@server/network/validation/socketPayloadValidators"; 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 cleanupExpiredBombDedup = (roomId: string, nowMs: number) => { const roomTable = roomBombDedupTable.get(roomId); @@ -37,25 +38,31 @@ } }; -const shouldBroadcastBombPlaced = (roomId: string, bombId: string, nowMs: number) => { +const shouldBroadcastBombPlaced = (roomId: string, dedupeKey: string, nowMs: number) => { cleanupExpiredBombDedup(roomId, nowMs); const roomTable = roomBombDedupTable.get(roomId) ?? new Map(); - if (roomTable.has(bombId)) { + if (roomTable.has(dedupeKey)) { return false; } const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; - roomTable.set(bombId, nowMs + ttlMs); + 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}`; +}; + /** ゲーム受信イベントごとの入力検証関数を保持するテーブル */ const gamePayloadValidators = { [protocol.SocketEvents.PING]: isPingPayload, [protocol.SocketEvents.MOVE]: isMovePayload, - [protocol.SocketEvents.PLACE_BOMB]: isBombPlacedPayload, + [protocol.SocketEvents.PLACE_BOMB]: isPlaceBombPayload, } as const; /** ゲームイベントの購読とユースケース呼び出しを設定する */ @@ -139,11 +146,17 @@ } const nowMs = Date.now(); - const bombId = createBombIdFromPayload(data); - if (!shouldBroadcastBombPlaced(roomId, bombId, nowMs)) { + const dedupeKey = `${socket.id}:${data.requestId}`; + if (!shouldBroadcastBombPlaced(roomId, dedupeKey, nowMs)) { return; } - common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, data); + const payload = { + ...data, + bombId: issueServerBombId(roomId), + ownerId: socket.id, + }; + + common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); }); }; diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index ceae7ba..1f950cc 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -2,7 +2,7 @@ * socketPayloadValidators * ソケット受信ペイロードの型ガードを提供する */ -import type { playerTypes, roomTypes, BombPlacedPayload } from "@repo/shared"; +import type { playerTypes, roomTypes, PlaceBombPayload } from "@repo/shared"; import type { PingPayload } from "@repo/shared"; const isFiniteNumber = (value: unknown): value is number => { @@ -28,14 +28,16 @@ return isFiniteNumber(candidate.x) && isFiniteNumber(candidate.y); }; -/** PLACE_BOMBイベントのペイロードが爆弾情報であるか判定する */ -export const isBombPlacedPayload = (value: unknown): value is BombPlacedPayload => { +/** PLACE_BOMBイベントのペイロードが爆弾設置要求であるか判定する */ +export const isPlaceBombPayload = (value: unknown): value is PlaceBombPayload => { if (typeof value !== "object" || value === null) { return false; } const candidate = value as Record; return ( + isNonEmptyString(candidate.requestId) + && isFiniteNumber(candidate.x) && isFiniteNumber(candidate.y) && isFiniteNumber(candidate.explodeAtElapsedMs) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f25e374..95c5a0c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,6 +31,7 @@ ServerToClientPayloadOf, CurrentPlayersPayload, BombPlacedPayload, + PlaceBombPayload, BombNetworkPayload, GameStartPayload, GameResultPayload, diff --git a/packages/shared/src/protocol/bombIdentity.ts b/packages/shared/src/protocol/bombIdentity.ts index ac2dcb0..0c51e66 100644 --- a/packages/shared/src/protocol/bombIdentity.ts +++ b/packages/shared/src/protocol/bombIdentity.ts @@ -10,5 +10,5 @@ /** 爆弾ペイロードから同期用IDを生成する */ export const createBombIdFromPayload = (payload: BombNetworkPayload): string => { - return `${payload.x}:${payload.y}:${payload.explodeAtElapsedMs}`; + return payload.bombId; }; diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index ede59e7..7d7c6f6 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -23,6 +23,7 @@ RemovePlayerPayload, GameStartPayload, MovePayload, + PlaceBombPayload, BombPlacedPayload, GameResultPayload, GameResultRanking, diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index bdd0978..20329b7 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -22,6 +22,7 @@ RemovePlayerPayload, GameStartPayload, MovePayload, + PlaceBombPayload, BombPlacedPayload, PingPayload, PongPayload, diff --git a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts index 9504411..2e79291 100644 --- a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts @@ -11,6 +11,7 @@ GameStartPayload, MovePayload, NewPlayerPayload, + PlaceBombPayload, PingPayload, PongPayload, RemovePlayerPayload, @@ -23,7 +24,7 @@ [SocketEvents.START_GAME]: undefined; [SocketEvents.READY_FOR_GAME]: undefined; [SocketEvents.MOVE]: MovePayload; - [SocketEvents.PLACE_BOMB]: BombPlacedPayload; + [SocketEvents.PLACE_BOMB]: PlaceBombPayload; [SocketEvents.PING]: PingPayload; }; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 54c8396..7353bd3 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -40,9 +40,16 @@ /** MOVE イベントで送受信する移動入力情報 */ export type MovePayload = PlayerMovePayload; -/** PLACE_BOMB / BOMB_PLACED イベントで送受信する爆弾情報 */ -export type BombPlacedPayload = { +/** PLACE_BOMB イベントで送受信する爆弾設置要求 */ +export type PlaceBombPayload = { + requestId: string; x: number; y: number; explodeAtElapsedMs: number; }; + +/** BOMB_PLACED イベントで送受信する爆弾確定情報 */ +export type BombPlacedPayload = PlaceBombPayload & { + bombId: string; + ownerId: string; +};