diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 979e679..7783fc6 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -6,6 +6,7 @@ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; import type { + BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, GameResultPayload, @@ -40,6 +41,8 @@ offGameResult: (callback: (payload: GameResultPayload) => void) => void; onBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; offBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; + onBombPlacedAck: (callback: (payload: BombPlacedAckPayload) => void) => void; + offBombPlacedAck: (callback: (payload: BombPlacedAckPayload) => void) => void; sendMove: (x: number, y: number) => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; readyForGame: () => void; @@ -107,6 +110,12 @@ offBombPlaced: (callback) => { offEvent(protocol.SocketEvents.BOMB_PLACED, callback); }, + onBombPlacedAck: (callback) => { + onEvent(protocol.SocketEvents.BOMB_PLACED_ACK, callback); + }, + offBombPlacedAck: (callback) => { + offEvent(protocol.SocketEvents.BOMB_PLACED_ACK, callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; emitEvent(protocol.SocketEvents.MOVE, payload); diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index c3c61d7..99066d8 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -4,7 +4,7 @@ * マップ,ネットワーク同期,ゲームループを統合する */ import { Application, Container, Ticker } from "pixi.js"; -import type { BombPlacedPayload } from "@repo/shared"; +import type { BombPlacedAckPayload, BombPlacedPayload } from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; import { GameMapController } from "./entities/map/GameMapController"; import { BombManager } from "./entities/bomb/BombManager"; @@ -46,8 +46,12 @@ return placed.tempBombId; } - public applyPlacedBomb(payload: BombPlacedPayload): void { - this.bombManager?.applyPlacedBomb(payload); + public applyPlacedBombFromOthers(payload: BombPlacedPayload): void { + this.bombManager?.applyPlacedBombFromOthers(payload); + } + + public applyPlacedBombAck(payload: BombPlacedAckPayload): void { + this.bombManager?.applyPlacedBombAck(payload); } // 入力と状態管理 @@ -96,8 +100,11 @@ gameMap: this.gameMap, onGameStart: this.setGameStart.bind(this), onGameEnd: this.lockInput.bind(this), - onBombPlacedFromNetwork: (payload) => { - this.applyPlacedBomb(payload); + onBombPlacedFromOthers: (payload) => { + this.applyPlacedBombFromOthers(payload); + }, + onBombPlacedAckFromNetwork: (payload) => { + this.applyPlacedBombAck(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 eed8e01..c4bebb1 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -5,6 +5,7 @@ */ import { Container } from "pixi.js"; import type { + BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, GameStartPayload, @@ -27,7 +28,8 @@ gameMap: GameMapController; onGameStart: (startTime: number) => void; onGameEnd: () => void; - onBombPlacedFromNetwork: (payload: BombPlacedPayload) => void; + onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -38,7 +40,8 @@ private gameMap: GameMapController; private onGameStart: (startTime: number) => void; private onGameEnd: () => void; - private onBombPlacedFromNetwork: (payload: BombPlacedPayload) => void; + private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; private isBound = false; private debugLog = (message: string) => { @@ -98,7 +101,11 @@ }; private handleBombPlaced = (payload: BombPlacedPayload) => { - this.onBombPlacedFromNetwork(payload); + this.onBombPlacedFromOthers(payload); + }; + + private handleBombPlacedAck = (payload: BombPlacedAckPayload) => { + this.onBombPlacedAckFromNetwork(payload); }; constructor({ @@ -108,7 +115,8 @@ gameMap, onGameStart, onGameEnd, - onBombPlacedFromNetwork, + onBombPlacedFromOthers, + onBombPlacedAckFromNetwork, }: GameNetworkSyncOptions) { this.worldContainer = worldContainer; this.players = players; @@ -116,7 +124,8 @@ this.gameMap = gameMap; this.onGameStart = onGameStart; this.onGameEnd = onGameEnd; - this.onBombPlacedFromNetwork = onBombPlacedFromNetwork; + this.onBombPlacedFromOthers = onBombPlacedFromOthers; + this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; } public bind() { @@ -130,6 +139,7 @@ socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); socketManager.game.onGameEnd(this.handleGameEnd); socketManager.game.onBombPlaced(this.handleBombPlaced); + socketManager.game.onBombPlacedAck(this.handleBombPlacedAck); this.isBound = true; } @@ -145,6 +155,7 @@ socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); socketManager.game.offGameEnd(this.handleGameEnd); socketManager.game.offBombPlaced(this.handleBombPlaced); + socketManager.game.offBombPlacedAck(this.handleBombPlacedAck); this.isBound = false; } diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index 00f4d28..de4f0f5 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -5,7 +5,11 @@ */ import type { Container } from "pixi.js"; import { config } from "@client/config"; -import type { BombPlacedPayload, PlaceBombPayload } from "@repo/shared"; +import type { + BombPlacedAckPayload, + 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"; @@ -85,19 +89,26 @@ }; } - /** サーバー確定イベントを反映し,必要なら仮IDから正式IDへ置換する */ - public applyPlacedBomb(payload: BombPlacedPayload): void { - if (payload.ownerId === this.myId) { - const tempBombId = this.pendingOwnRequestToTempBombId.get(payload.requestId); - if (tempBombId) { - this.removePendingRequestByRequestId(payload.requestId); - if (tempBombId !== payload.bombId) { - this.removeBomb(tempBombId); - } - } + /** 他プレイヤー向けの爆弾確定イベントを反映する */ + public applyPlacedBombFromOthers(payload: BombPlacedPayload): void { + this.upsertBomb(payload.bombId, this.toRenderPayload(payload)); + } + + /** 設置者本人向けACKを反映し,仮IDから正式IDへ置換する */ + public applyPlacedBombAck(payload: BombPlacedAckPayload): void { + const tempBombId = this.pendingOwnRequestToTempBombId.get(payload.requestId); + if (!tempBombId) { + return; } - this.upsertBomb(payload.bombId, this.toRenderPayload(payload)); + const tempPayload = this.bombRenderPayloadById.get(tempBombId); + this.removePendingRequestByRequestId(payload.requestId); + if (!tempPayload || tempBombId === payload.bombId) { + return; + } + + this.removeBomb(tempBombId); + this.upsertBomb(payload.bombId, tempPayload); } /** 描画ペイロードで指定IDの爆弾を追加または更新する */ @@ -170,9 +181,9 @@ }; } - private createRequestId(elapsedMs: number): string { + private createRequestId(_elapsedMs: number): string { this.requestSerial += 1; - return `${this.myId}:${elapsedMs}:${this.requestSerial}`; + return `${this.requestSerial}`; } private createTempBombId(requestId: string): string { diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 02578b3..53bfee3 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -3,6 +3,7 @@ * ゲーム系ユースケースが利用する入力ポートと出力ポートの契約を定義する */ import type { + BombPlacedAckPayload, BombPlacedPayload, gameTypes, playerTypes, @@ -69,7 +70,12 @@ /** 爆弾ユースケースが利用する送信出力ポート */ export interface BombOutputPort { - publishBombPlacedToRoom(roomId: roomTypes.Room["roomId"], payload: BombPlacedPayload): void; + publishBombPlacedToOthersInRoom( + roomId: roomTypes.Room["roomId"], + ownerSocketId: string, + payload: BombPlacedPayload + ): void; + publishBombPlacedAckToSocket(socketId: string, payload: BombPlacedAckPayload): void; } /** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 54a5abf..5759e6f 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -122,7 +122,7 @@ } public issueServerBombId(): string { - return this.bombStateStore.issueServerBombId(this.roomId); + return this.bombStateStore.issueServerBombId(); } public dispose(): void { diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index adbf947..f3fde0e 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -8,6 +8,7 @@ PlaceBombInput, } from "../ports/gameUseCasePorts"; import { + createBombPlacedAckPayload, createBombDedupeKey, createBombPlacedPayload, } from "@server/domains/game/entities/bomb/bombPlacement"; @@ -38,12 +39,22 @@ return; } - output.publishBombPlacedToRoom( + const bombId = bombStore.issueServerBombId(roomId); + + output.publishBombPlacedToOthersInRoom( roomId, + input.socketId, createBombPlacedPayload({ payload: input.payload, - bombId: bombStore.issueServerBombId(roomId), - ownerId: input.socketId, + bombId, + }) + ); + + output.publishBombPlacedAckToSocket( + input.socketId, + createBombPlacedAckPayload({ + requestId: input.payload.requestId, + bombId, }) ); }; diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 7ec726b..d4eb749 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -6,6 +6,13 @@ import { logEvent } from "@server/logging/logger"; import { gameUseCaseLogEvents, logResults, logScopes } from "@server/logging/index"; +const excludeRecipientFromPlayerUpdates = ( + playerUpdates: TPlayerUpdate[], + recipientId: string +): TPlayerUpdate[] => { + return playerUpdates.filter((playerUpdate) => playerUpdate.id !== recipientId); +}; + type StartGameUseCaseParams = { roomId: string; playerIds: string[]; @@ -36,8 +43,9 @@ (tickData) => { if (tickData.playerUpdates.length > 0) { playerIds.forEach((playerId) => { - const updatesForPlayer = tickData.playerUpdates.filter( - (playerUpdate) => playerUpdate.id !== playerId + const updatesForPlayer = excludeRecipientFromPlayerUpdates( + tickData.playerUpdates, + playerId ); if (updatesForPlayer.length === 0) { diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index 28a0719..92a2ea4 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -20,10 +20,8 @@ } /** セッション単位の連番からサーバー採番の爆弾IDを生成する */ - public issueServerBombId(roomId: string): string { - // roomId はセッションを外部参照するためのID名前空間として利用する + public issueServerBombId(): string { const { bombId, nextSerial } = issueServerBombId({ - roomId, currentSerial: this.bombSerial, }); this.bombSerial = nextSerial; diff --git a/apps/server/src/domains/game/entities/bomb/bombIdentity.ts b/apps/server/src/domains/game/entities/bomb/bombIdentity.ts index eaa9ba2..3eb5a31 100644 --- a/apps/server/src/domains/game/entities/bomb/bombIdentity.ts +++ b/apps/server/src/domains/game/entities/bomb/bombIdentity.ts @@ -4,19 +4,16 @@ */ type IssueServerBombIdParams = { - // セッションに紐づく外部公開用のID名前空間 - roomId: string; currentSerial: number; }; /** 次のサーバー採番爆弾IDと更新後シリアルを返す */ export const issueServerBombId = ({ - roomId, currentSerial, }: IssueServerBombIdParams): { bombId: string; nextSerial: number } => { const nextSerial = currentSerial + 1; return { - bombId: `${roomId}:${nextSerial}`, + bombId: `${nextSerial}`, nextSerial, }; }; diff --git a/apps/server/src/domains/game/entities/bomb/bombPlacement.ts b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts index 98492a0..991944a 100644 --- a/apps/server/src/domains/game/entities/bomb/bombPlacement.ts +++ b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts @@ -2,7 +2,7 @@ * bombPlacement * 爆弾設置時の識別キー生成と確定ペイロード組み立てを提供する */ -import type { BombPlacedPayload, PlaceBombPayload } from "@repo/shared"; +import type { BombPlacedAckPayload, BombPlacedPayload, PlaceBombPayload } from "@repo/shared"; /** 重複排除に利用する爆弾設置要求キーを生成する */ export const createBombDedupeKey = (ownerId: string, requestId: string): string => { @@ -12,18 +12,33 @@ type CreateBombPlacedPayloadParams = { payload: PlaceBombPayload; bombId: string; - ownerId: string; }; -/** 爆弾確定通知で配信するペイロードを生成する */ +/** 爆弾確定通知で他プレイヤーへ配信するペイロードを生成する */ export const createBombPlacedPayload = ({ payload, bombId, - ownerId, }: CreateBombPlacedPayloadParams): BombPlacedPayload => { return { - ...payload, bombId, - ownerId, + x: payload.x, + y: payload.y, + explodeAtElapsedMs: payload.explodeAtElapsedMs, + }; +}; + +type CreateBombPlacedAckPayloadParams = { + requestId: string; + bombId: string; +}; + +/** 爆弾確定通知で設置者本人へ配信するACKペイロードを生成する */ +export const createBombPlacedAckPayload = ({ + requestId, + bombId, +}: CreateBombPlacedAckPayloadParams): BombPlacedAckPayload => { + return { + requestId, + bombId, }; }; diff --git a/apps/server/src/network/adapters/socketEmitters.ts b/apps/server/src/network/adapters/socketEmitters.ts index 9ced072..dfe1fc3 100644 --- a/apps/server/src/network/adapters/socketEmitters.ts +++ b/apps/server/src/network/adapters/socketEmitters.ts @@ -15,6 +15,11 @@ (roomId: string, event: TEvent, payload: ServerToClientPayloadOf): void; }; +type EmitToRoomExceptSocket = { + (roomId: string, excludedSocketId: string, event: TEvent): void; + (roomId: string, excludedSocketId: string, event: TEvent, payload: ServerToClientPayloadOf): void; +}; + type EmitToSocket = { (event: TEvent): void; (event: TEvent, payload: ServerToClientPayloadOf): void; @@ -51,6 +56,17 @@ }; }; +/** ルーム送信時に特定ソケットを除外する送信関数を生成する */ +export const createEmitToRoomExceptSocket = (io: Server): EmitToRoomExceptSocket => { + return (roomId: string, excludedSocketId: string, event: SocketEventName, payload?: unknown) => { + emitWithOptionalPayload( + (eventName, body) => io.to(roomId).except(excludedSocketId).emit(eventName, body), + event, + payload + ); + }; +}; + /** 単一ソケット向けの送信関数を生成する */ export const createEmitToSocket = (socket: Socket): EmitToSocket => { return (event: SocketEventName, payload?: unknown) => { diff --git a/apps/server/src/network/handlers/CommonHandler.ts b/apps/server/src/network/handlers/CommonHandler.ts index 5e728b4..6348c9d 100644 --- a/apps/server/src/network/handlers/CommonHandler.ts +++ b/apps/server/src/network/handlers/CommonHandler.ts @@ -6,6 +6,7 @@ import { createEmitToAll, createEmitToRoom, + createEmitToRoomExceptSocket, createEmitToSocket, createEmitToSocketById, } from "@server/network/adapters/socketEmitters"; @@ -14,6 +15,7 @@ export type CommonHandlerContext = { emitToAll: ReturnType; emitToRoom: ReturnType; + emitToRoomExceptSocket: ReturnType; emitToSocket: ReturnType; emitToSocketById: ReturnType; }; @@ -26,6 +28,7 @@ return { emitToAll: createEmitToAll(io), emitToRoom: createEmitToRoom(io), + emitToRoomExceptSocket: createEmitToRoomExceptSocket(io), emitToSocket: createEmitToSocket(socket), emitToSocketById: createEmitToSocketById(io), }; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 728f8c1..4b18fd0 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -5,6 +5,7 @@ import { Server } from "socket.io"; import { protocol } from "@repo/shared"; import type { + BombPlacedAckPayload, BombPlacedPayload, GameStartPayload, GameResultPayload, @@ -60,8 +61,11 @@ publishGameStartToSocket: (payload: GameStartPayload) => { common.emitToSocket(protocol.SocketEvents.GAME_START, payload); }, - publishBombPlacedToRoom: (roomId: RoomId, payload: BombPlacedPayload) => { - common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + publishBombPlacedToOthersInRoom: (roomId: RoomId, ownerSocketId: string, payload: BombPlacedPayload) => { + common.emitToRoomExceptSocket(roomId, ownerSocketId, protocol.SocketEvents.BOMB_PLACED, payload); + }, + publishBombPlacedAckToSocket: (socketId: string, payload: BombPlacedAckPayload) => { + common.emitToSocketById(socketId, protocol.SocketEvents.BOMB_PLACED_ACK, payload); }, }; }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 95c5a0c..49f47b1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,6 +31,7 @@ ServerToClientPayloadOf, CurrentPlayersPayload, BombPlacedPayload, + BombPlacedAckPayload, PlaceBombPayload, BombNetworkPayload, GameStartPayload, diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 7d7c6f6..cf0976f 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -25,6 +25,7 @@ MovePayload, PlaceBombPayload, BombPlacedPayload, + BombPlacedAckPayload, GameResultPayload, GameResultRanking, } from "./payloads/gamePayloads"; diff --git a/packages/shared/src/protocol/events.ts b/packages/shared/src/protocol/events.ts index 20329b7..cfef91b 100644 --- a/packages/shared/src/protocol/events.ts +++ b/packages/shared/src/protocol/events.ts @@ -24,6 +24,7 @@ MovePayload, PlaceBombPayload, BombPlacedPayload, + BombPlacedAckPayload, PingPayload, PongPayload, JoinRoomPayload, diff --git a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts index 2e79291..83b80ea 100644 --- a/packages/shared/src/protocol/maps/gameEventPayloadMap.ts +++ b/packages/shared/src/protocol/maps/gameEventPayloadMap.ts @@ -5,6 +5,7 @@ */ import { SocketEvents } from "../socketEvents"; import type { + BombPlacedAckPayload, BombPlacedPayload, CurrentPlayersPayload, GameResultPayload, @@ -37,6 +38,7 @@ [SocketEvents.REMOVE_PLAYER]: RemovePlayerPayload; [SocketEvents.UPDATE_MAP_CELLS]: UpdateMapCellsPayload; [SocketEvents.BOMB_PLACED]: BombPlacedPayload; + [SocketEvents.BOMB_PLACED_ACK]: BombPlacedAckPayload; [SocketEvents.PONG]: PongPayload; [SocketEvents.GAME_END]: undefined; [SocketEvents.GAME_RESULT]: GameResultPayload; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 7353bd3..f28a522 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -48,8 +48,16 @@ explodeAtElapsedMs: number; }; -/** BOMB_PLACED イベントで送受信する爆弾確定情報 */ -export type BombPlacedPayload = PlaceBombPayload & { +/** BOMB_PLACED イベントで送受信する他プレイヤー向け爆弾確定情報 */ +export type BombPlacedPayload = { bombId: string; - ownerId: string; + x: number; + y: number; + explodeAtElapsedMs: number; +}; + +/** BOMB_PLACED_ACK イベントで送受信する設置者向け確定情報 */ +export type BombPlacedAckPayload = { + bombId: string; + requestId: string; }; diff --git a/packages/shared/src/protocol/socketEvents.ts b/packages/shared/src/protocol/socketEvents.ts index 2517acf..d93cb2f 100644 --- a/packages/shared/src/protocol/socketEvents.ts +++ b/packages/shared/src/protocol/socketEvents.ts @@ -27,6 +27,7 @@ PLACE_BOMB: "place-bomb", UPDATE_MAP_CELLS: "update_map_cells", BOMB_PLACED: "bomb-placed", + BOMB_PLACED_ACK: "bomb-placed-ack", // 時間同期・ゲーム進行関連 PING: "ping",