diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index ea9f08e..c5f42c6 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -17,6 +17,7 @@ MovePayload, NewPlayerPayload, PlaceBombPayload, + PlayerDeadPayload, RemovePlayerPayload, UpdateMapCellsPayload, UpdatePlayersPayload, @@ -46,6 +47,8 @@ offBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; onBombPlacedAck: (callback: (payload: BombPlacedAckPayload) => void) => void; offBombPlacedAck: (callback: (payload: BombPlacedAckPayload) => void) => void; + onPlayerDead: (callback: (payload: PlayerDeadPayload) => void) => void; + offPlayerDead: (callback: (payload: PlayerDeadPayload) => void) => void; sendMove: (x: number, y: number) => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; sendBombHitReport: (payload: BombHitReportPayload) => void; @@ -108,6 +111,9 @@ const bombPlacedAckSubscription = createSubscriptionPair( protocol.SocketEvents.BOMB_PLACED_ACK ); + const playerDeadSubscription = createSubscriptionPair( + protocol.SocketEvents.PLAYER_DEAD + ); const sendMovePayload = createPayloadSender(protocol.SocketEvents.MOVE); const sendPlaceBombPayload = createPayloadSender(protocol.SocketEvents.PLACE_BOMB); const sendBombHitReportPayload = createPayloadSender( @@ -179,6 +185,12 @@ offBombPlacedAck: (callback) => { bombPlacedAckSubscription.off(callback); }, + onPlayerDead: (callback) => { + playerDeadSubscription.on(callback); + }, + offPlayerDead: (callback) => { + playerDeadSubscription.off(callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; sendMovePayload(payload); diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 498b49c..a27851a 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -4,7 +4,10 @@ * マップ,ネットワーク同期,ゲームループを統合する */ import { Application, Container, Ticker } from "pixi.js"; -import type { BombPlacedAckPayload, BombPlacedPayload } from "@repo/shared"; +import type { + BombPlacedAckPayload, + BombPlacedPayload, +} from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; import { AppearanceResolver } from "./application/AppearanceResolver"; import { GameMapController } from "./entities/map/GameMapController"; @@ -14,6 +17,7 @@ import { GameLoop } from "./application/GameLoop"; import { BombHitContextProvider } from "./application/BombHitContextProvider"; import { BombHitOrchestrator } from "./application/BombHitOrchestrator"; +import { PlayerDeathPolicy } from "./application/PlayerDeathPolicy"; import type { BombHitEvaluationResult } from "./application/BombHitOrchestrator"; import type { GamePlayers } from "./application/game.types"; @@ -31,6 +35,7 @@ private bombHitOrchestrator: BombHitOrchestrator | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; + private playerDeathPolicy: PlayerDeathPolicy; private reportedBombHitIds = new Set(); // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ @@ -86,6 +91,10 @@ this.app = new Application(); this.worldContainer = new Container(); this.worldContainer.sortableChildren = true; + this.playerDeathPolicy = new PlayerDeathPolicy({ + myId: this.myId, + lockInput: this.lockInput.bind(this), + }); } /** @@ -162,6 +171,9 @@ onBombPlacedAckFromNetwork: (payload) => { this.applyPlacedBombAck(payload); }, + onPlayerDeadFromNetwork: (payload) => { + this.playerDeathPolicy.applyPlayerDeadEvent(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 5d1f28a..578f5ec 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -10,12 +10,13 @@ CurrentPlayersPayload, GameStartPayload, NewPlayerPayload, + PlayerDeadPayload, RemovePlayerPayload, UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; import { socketManager } from "@client/network/SocketManager"; -import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import { AppearanceResolver } from "./AppearanceResolver"; import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; import type { GamePlayers } from "./game.types"; @@ -32,6 +33,12 @@ onGameEnd: () => void; onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; +}; + +type SocketSubscription = { + bind: () => void; + unbind: () => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -45,6 +52,8 @@ private onGameEnd: () => void; private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + private onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; + private socketSubscriptions: SocketSubscription[]; private isBound = false; private debugLog = (message: string) => { @@ -113,6 +122,10 @@ this.onBombPlacedAckFromNetwork(payload); }; + private handlePlayerDead = (payload: PlayerDeadPayload) => { + this.onPlayerDeadFromNetwork(payload); + }; + constructor({ worldContainer, players, @@ -123,6 +136,7 @@ onGameEnd, onBombPlacedFromOthers, onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork, }: GameNetworkSyncOptions) { this.worldContainer = worldContainer; this.players = players; @@ -133,20 +147,57 @@ this.onGameEnd = onGameEnd; this.onBombPlacedFromOthers = onBombPlacedFromOthers; this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; + this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; + this.socketSubscriptions = [ + { + bind: () => socketManager.game.onCurrentPlayers(this.handleCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(this.handleCurrentPlayers), + }, + { + bind: () => socketManager.game.onNewPlayer(this.handleNewPlayer), + unbind: () => socketManager.game.offNewPlayer(this.handleNewPlayer), + }, + { + bind: () => socketManager.game.onGameStart(this.handleGameStart), + unbind: () => socketManager.game.offGameStart(this.handleGameStart), + }, + { + bind: () => socketManager.game.onUpdatePlayers(this.handlePlayerUpdates), + unbind: () => socketManager.game.offUpdatePlayers(this.handlePlayerUpdates), + }, + { + bind: () => socketManager.game.onRemovePlayer(this.handleRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(this.handleRemovePlayer), + }, + { + bind: () => socketManager.game.onUpdateMapCells(this.handleUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(this.handleUpdateMapCells), + }, + { + bind: () => socketManager.game.onGameEnd(this.handleGameEnd), + unbind: () => socketManager.game.offGameEnd(this.handleGameEnd), + }, + { + bind: () => socketManager.game.onBombPlaced(this.handleBombPlaced), + unbind: () => socketManager.game.offBombPlaced(this.handleBombPlaced), + }, + { + bind: () => socketManager.game.onBombPlacedAck(this.handleBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(this.handleBombPlacedAck), + }, + { + bind: () => socketManager.game.onPlayerDead(this.handlePlayerDead), + unbind: () => socketManager.game.offPlayerDead(this.handlePlayerDead), + }, + ]; } public bind() { if (this.isBound) return; - socketManager.game.onCurrentPlayers(this.handleCurrentPlayers); - socketManager.game.onNewPlayer(this.handleNewPlayer); - socketManager.game.onGameStart(this.handleGameStart); - socketManager.game.onUpdatePlayers(this.handlePlayerUpdates); - socketManager.game.onRemovePlayer(this.handleRemovePlayer); - socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); - socketManager.game.onGameEnd(this.handleGameEnd); - socketManager.game.onBombPlaced(this.handleBombPlaced); - socketManager.game.onBombPlacedAck(this.handleBombPlacedAck); + this.socketSubscriptions.forEach((subscription) => { + subscription.bind(); + }); this.isBound = true; } @@ -154,15 +205,9 @@ public unbind() { if (!this.isBound) return; - socketManager.game.offCurrentPlayers(this.handleCurrentPlayers); - socketManager.game.offNewPlayer(this.handleNewPlayer); - socketManager.game.offGameStart(this.handleGameStart); - socketManager.game.offUpdatePlayers(this.handlePlayerUpdates); - socketManager.game.offRemovePlayer(this.handleRemovePlayer); - socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); - socketManager.game.offGameEnd(this.handleGameEnd); - socketManager.game.offBombPlaced(this.handleBombPlaced); - socketManager.game.offBombPlacedAck(this.handleBombPlacedAck); + this.socketSubscriptions.forEach((subscription) => { + subscription.unbind(); + }); this.isBound = false; } diff --git a/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts b/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts new file mode 100644 index 0000000..207d74f --- /dev/null +++ b/apps/client/src/scenes/game/application/PlayerDeathPolicy.ts @@ -0,0 +1,26 @@ +import type { PlayerDeadPayload } from "@repo/shared"; + +type PlayerDeathPolicyParams = { + myId: string; + lockInput: () => void; +}; + +/** 被弾後のローカルプレイヤー処理ポリシーを管理する */ +export class PlayerDeathPolicy { + private myId: string; + private lockInput: () => void; + + constructor({ myId, lockInput }: PlayerDeathPolicyParams) { + this.myId = myId; + this.lockInput = lockInput; + } + + /** サーバーからの死亡通知に応じてローカル被弾後処理を適用する */ + public applyPlayerDeadEvent(payload: PlayerDeadPayload): void { + if (payload.playerId !== this.myId) { + return; + } + + this.lockInput(); + } +} diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index b495a92..d97598c 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -82,6 +82,11 @@ return this.lifecycleService.shouldBroadcastBombPlaced(dedupeKey, nowMs); } + // 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する + shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return this.lifecycleService.shouldBroadcastBombHitReport(dedupeKey, nowMs); + } + // サーバー採番の爆弾IDを生成する issueServerBombId(): string { return this.lifecycleService.issueServerBombId(); diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index d7e1845..7cf0308 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -119,6 +119,11 @@ issueServerBombId(): string; } +/** 被弾報告ユースケースが利用する重複排除入力ポート */ +export interface BombHitReportValidationPort { + shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; +} + /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; @@ -130,6 +135,7 @@ export type ReportBombHitInput = { socketId: string; payload: BombHitReportPayload; + nowMs: number; }; /** 被弾報告ユースケースが利用する出力ポート */ diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 107f3ab..fff0b67 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -152,6 +152,10 @@ return this.bombStateStore.shouldBroadcastBombPlaced(dedupeKey, nowMs); } + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return this.bombStateStore.shouldBroadcastBombHitReport(dedupeKey, nowMs); + } + public issueServerBombId(): string { return this.bombStateStore.issueServerBombId(); } diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 3fb9d09..a72d78a 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -42,6 +42,13 @@ ); } + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return ( + this.sessionRef.current?.shouldBroadcastBombHitReport(dedupeKey, nowMs) ?? + false + ); + } + public issueServerBombId(): string { const session = this.sessionRef.current; if (!session) { diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index 5194609..f65ccdf 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -4,22 +4,51 @@ */ import type { BombHitOutputPort, + BombHitReportValidationPort, ReportBombHitInput, } from "../ports/gameUseCasePorts"; +import { createBombHitReportDedupeKey } from "@server/domains/game/entities/bomb/bombHitReport"; type ReportBombHitUseCaseParams = { roomId: string; + validation: BombHitReportValidationPort; input: ReportBombHitInput; output: BombHitOutputPort; }; +/** 受信した被弾報告を処理対象にすべきか判定する */ +const shouldPublishPlayerDeadFromBombHit = ( + validation: BombHitReportValidationPort, + input: ReportBombHitInput, +): boolean => { + const dedupeKey = createBombHitReportDedupeKey( + input.socketId, + input.payload.bombId, + ); + return validation.shouldBroadcastBombHitReport(dedupeKey, input.nowMs); +}; + +/** 被弾報告を死亡通知へ変換して配信する */ +const publishPlayerDeadFromBombHit = ( + roomId: string, + input: ReportBombHitInput, + output: BombHitOutputPort, +): void => { + output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { + playerId: input.socketId, + }); +}; + /** 被弾報告を受け取り,死亡通知を同一ルームへ配信する */ export const reportBombHitUseCase = ({ roomId, + validation, input, output, }: ReportBombHitUseCaseParams): void => { - output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { - playerId: input.socketId, - }); + if (!shouldPublishPlayerDeadFromBombHit(validation, input)) { + return; + } + + publishPlayerDeadFromBombHit(roomId, input, output); }; diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index 92a2ea4..ecb645d 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -3,11 +3,15 @@ * セッション単位の爆弾重複排除状態と採番状態を管理する */ import { issueServerBombId } from "./bombIdentity.js"; -import { shouldBroadcastBombPlaced } from "./bombDedup.js"; +import { + shouldBroadcastBombHitReport, + shouldBroadcastBombPlaced, +} from "./bombDedup.js"; /** セッション単位の爆弾重複排除状態と採番状態を保持するストア */ export class BombStateStore { private bombDedupTable = new Map(); + private bombHitReportDedupTable = new Map(); private bombSerial = 0; /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ @@ -19,6 +23,15 @@ }); } + /** 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ + public shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean { + return shouldBroadcastBombHitReport({ + dedupTable: this.bombHitReportDedupTable, + dedupeKey, + nowMs, + }); + } + /** セッション単位の連番からサーバー採番の爆弾IDを生成する */ public issueServerBombId(): string { const { bombId, nextSerial } = issueServerBombId({ diff --git a/apps/server/src/domains/game/entities/bomb/bombDedup.ts b/apps/server/src/domains/game/entities/bomb/bombDedup.ts index ff57c39..d28ba33 100644 --- a/apps/server/src/domains/game/entities/bomb/bombDedup.ts +++ b/apps/server/src/domains/game/entities/bomb/bombDedup.ts @@ -10,6 +10,12 @@ nowMs: number; }; +type ShouldBroadcastBombHitReportParams = { + dedupTable: Map; + dedupeKey: string; + nowMs: number; +}; + /** 重複排除テーブルの期限切れエントリを削除する */ export const cleanupExpiredBombDedup = ( dedupTable: Map, @@ -38,3 +44,20 @@ dedupTable.set(dedupeKey, nowMs + ttlMs); return true; }; + +/** 被弾報告イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ +export const shouldBroadcastBombHitReport = ({ + dedupTable, + dedupeKey, + nowMs, +}: ShouldBroadcastBombHitReportParams): boolean => { + cleanupExpiredBombDedup(dedupTable, nowMs); + + if (dedupTable.has(dedupeKey)) { + return false; + } + + const ttlMs = config.GAME_CONFIG.BOMB_FUSE_MS + config.GAME_CONFIG.BOMB_DEDUP_EXTRA_TTL_MS; + dedupTable.set(dedupeKey, nowMs + ttlMs); + return true; +}; diff --git a/apps/server/src/domains/game/entities/bomb/bombHitReport.ts b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts new file mode 100644 index 0000000..208d342 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts @@ -0,0 +1,9 @@ +/** + * bombHitReport + * 被弾報告イベントの重複排除キー生成を提供する + */ + +/** 被弾報告の重複排除に利用するキーを生成する */ +export const createBombHitReportDedupeKey = (reporterSocketId: string, bombId: string): string => { + return `${reporterSocketId}:${bombId}`; +}; diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index f727fcb..d72a13c 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -4,6 +4,7 @@ */ import type { roomTypes } from "@repo/shared"; import type { + BombHitReportValidationPort, BombPlacementPort, DisconnectPlayerPort, MovePlayerPort, @@ -17,6 +18,7 @@ & ReadyForGamePort & MovePlayerPort & BombPlacementPort + & BombHitReportValidationPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index f848eec..bcc0631 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -150,9 +150,11 @@ reportBombHitUseCase({ roomId: runtime.roomId, + validation: runtime.gameManager, input: { socketId: socket.id, payload: data, + nowMs: Date.now(), }, output: gameOutputAdapter, });