diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index e1e8da3..4ece4b1 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -109,8 +109,8 @@ players: this.players, myId: this.myId, acquireInputLock: this.lockInput.bind(this), - onSendBombHitReport: (bombId) => { - gameActionSender.sendBombHitReport(bombId); + onSendBombHitReport: (bombId, targetPlayerId) => { + gameActionSender.sendBombHitReport(bombId, targetPlayerId); }, }); this.runtime = new GameSceneRuntime({ diff --git a/apps/client/src/scenes/game/application/BombHitContextProvider.ts b/apps/client/src/scenes/game/application/BombHitContextProvider.ts index 888c77e..584d88e 100644 --- a/apps/client/src/scenes/game/application/BombHitContextProvider.ts +++ b/apps/client/src/scenes/game/application/BombHitContextProvider.ts @@ -8,6 +8,11 @@ import type { TeamCollisionCircle } from "@client/scenes/game/entities/bomb/BombHitDetector"; import type { GamePlayers } from "./game.types"; +/** 被弾判定と報告に利用するプレイヤー円情報 */ +export type ReportablePlayerCircle = TeamCollisionCircle & { + playerId: string; +}; + /** 判定用コンテキスト供給クラスの初期化入力 */ type BombHitContextProviderOptions = { players: GamePlayers; @@ -41,4 +46,29 @@ teamId: snapshot.teamId, }; } + + /** 被弾報告対象として扱うプレイヤー円情報を取得する */ + public getReportablePlayerCircles(): ReportablePlayerCircle[] { + const reportablePlayers = Object.entries(this.players).filter(([playerId]) => { + return playerId === this.myId || this.isBotPlayerId(playerId); + }); + + return reportablePlayers.map(([playerId, controller]) => { + const position = controller.getPosition(); + const snapshot = controller.getSnapshot(); + + return { + playerId, + x: position.x, + y: position.y, + radius: config.GAME_CONFIG.PLAYER_RADIUS, + teamId: snapshot.teamId, + }; + }); + } + + /** BotプレイヤーIDかどうかを判定する */ + private isBotPlayerId(playerId: string): boolean { + return playerId.startsWith("bot:"); + } } diff --git a/apps/client/src/scenes/game/application/BombHitOrchestrator.ts b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts index 3d6b337..4cebadc 100644 --- a/apps/client/src/scenes/game/application/BombHitOrchestrator.ts +++ b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts @@ -13,7 +13,10 @@ }; /** 爆弾爆発イベントの判定結果を表す型 */ -export type BombHitEvaluationResult = "duplicate" | "missing-local-player" | "no-hit" | "hit"; +export type BombHitEvaluationResult = { + status: "duplicate" | "missing-local-player" | "no-hit" | "hit"; + hitPlayerIds: string[]; +}; /** 爆弾当たり判定の実行順序を制御する */ export class BombHitOrchestrator { @@ -27,30 +30,49 @@ /** 爆弾爆発イベントを受けて当たり判定を実行し結果を返す */ public handleBombExploded(payload: BombExplodedPayload): BombHitEvaluationResult { if (this.handledBombIds.has(payload.bombId)) { - return "duplicate"; + return { + status: "duplicate", + hitPlayerIds: [], + }; } this.handledBombIds.add(payload.bombId); const localPlayer = this.contextProvider.getLocalPlayerCircle(); if (!localPlayer) { - return "missing-local-player"; + return { + status: "missing-local-player", + hitPlayerIds: [], + }; } - const result = checkBombHit({ - bomb: { - x: payload.x, - y: payload.y, - radius: payload.radius, - teamId: payload.teamId, - }, - player: localPlayer, - }); + const reportablePlayers = this.contextProvider.getReportablePlayerCircles(); + const hitPlayerIds = reportablePlayers + .filter((player) => { + const result = checkBombHit({ + bomb: { + x: payload.x, + y: payload.y, + radius: payload.radius, + teamId: payload.teamId, + }, + player, + }); - if (!result.isHit) { - return "no-hit"; + return result.isHit; + }) + .map((player) => player.playerId); + + if (hitPlayerIds.length === 0) { + return { + status: "no-hit", + hitPlayerIds: [], + }; } - return "hit"; + return { + status: "hit", + hitPlayerIds, + }; } diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index 2fb08fc..6597771 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -18,13 +18,16 @@ players: GamePlayers; myId: string; acquireInputLock: () => () => void; - onSendBombHitReport: (bombId: string) => void; + onSendBombHitReport: (bombId: string, targetPlayerId: string) => void; }; /** 被弾関連ライフサイクルの制御を担当する */ export class CombatLifecycleFacade { private readonly myId: string; - private readonly onSendBombHitReport: (bombId: string) => void; + private readonly onSendBombHitReport: ( + bombId: string, + targetPlayerId: string, + ) => void; private readonly bombHitOrchestrator: BombHitOrchestrator; private readonly playerDeathPolicy: PlayerDeathPolicy; private readonly playerHitEffectOrchestrator: PlayerHitEffectOrchestrator; @@ -59,13 +62,19 @@ /** 爆弾爆発時の判定と後続処理を実行する */ public handleBombExploded(payload: BombExplodedPayload): void { const result = this.bombHitOrchestrator.handleBombExploded(payload); - if (!this.hitReportPolicy.shouldSendReport(result, payload.bombId)) { - return; + const hasLocalHit = result.hitPlayerIds.includes(this.myId); + if (hasLocalHit) { + this.playerDeathPolicy.applyLocalHitStun(); + this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); } - this.playerDeathPolicy.applyLocalHitStun(); - this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); - this.onSendBombHitReport(payload.bombId); + result.hitPlayerIds.forEach((targetPlayerId) => { + if (!this.hitReportPolicy.shouldSendReport(result.status, payload.bombId, targetPlayerId)) { + return; + } + + this.onSendBombHitReport(payload.bombId, targetPlayerId); + }); } /** ネットワーク被弾通知を適用する */ diff --git a/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts b/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts index bbd607c..e65cd87 100644 --- a/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts +++ b/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts @@ -9,24 +9,29 @@ /** 爆弾被弾報告の送信可否を管理する */ export class HitReportPolicy { - private readonly reportedBombHitIds = new Set(); + private readonly reportedBombHitKeys = new Set(); /** 被弾報告を送信すべき場合に true を返す */ - public shouldSendReport(result: HitEvaluationResult | undefined, bombId: string): boolean { + public shouldSendReport( + result: HitEvaluationResult | undefined, + bombId: string, + targetPlayerId: string, + ): boolean { if (result !== "hit") { return false; } - if (this.reportedBombHitIds.has(bombId)) { + const reportKey = `${bombId}:${targetPlayerId}`; + if (this.reportedBombHitKeys.has(reportKey)) { return false; } - this.reportedBombHitIds.add(bombId); + this.reportedBombHitKeys.add(reportKey); return true; } /** 判定済みIDをすべて破棄する */ public clear(): void { - this.reportedBombHitIds.clear(); + this.reportedBombHitKeys.clear(); } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/network/GameActionSender.ts b/apps/client/src/scenes/game/application/network/GameActionSender.ts index 8c22eb2..358ec60 100644 --- a/apps/client/src/scenes/game/application/network/GameActionSender.ts +++ b/apps/client/src/scenes/game/application/network/GameActionSender.ts @@ -10,7 +10,7 @@ export type GameActionSender = { readyForGame: () => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; - sendBombHitReport: (bombId: string) => void; + sendBombHitReport: (bombId: string, targetPlayerId: string) => void; }; /** ソケット経由でゲーム中送信アクションを実行する実装 */ @@ -26,7 +26,7 @@ } /** 被弾報告をサーバーへ送信する */ - public sendBombHitReport(bombId: string): void { - socketManager.game.sendBombHitReport({ bombId }); + public sendBombHitReport(bombId: string, targetPlayerId: string): void { + socketManager.game.sendBombHitReport({ bombId, targetPlayerId }); } } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index 9fb2a40..927f8ba 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -24,8 +24,10 @@ input: ReportBombHitInput, output: BombHitOutputPort, ): void => { - output.publishPlayerDeadToOthersInRoom(roomId, input.socketId, { - playerId: input.socketId, + const deadPlayerId = input.payload.targetPlayerId ?? input.socketId; + + output.publishPlayerDeadToOthersInRoom(roomId, deadPlayerId, { + playerId: deadPlayerId, }); }; diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts b/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts index 7dfd691..6437ea4 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts @@ -12,6 +12,7 @@ const dedupeKey = createBombHitReportDedupeKey( input.socketId, input.payload.bombId, + input.payload.targetPlayerId, ); return validation.shouldBroadcastBombHitReport(dedupeKey, input.nowMs); }; diff --git a/apps/server/src/domains/game/entities/bomb/bombHitReport.ts b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts index 208d342..24478f8 100644 --- a/apps/server/src/domains/game/entities/bomb/bombHitReport.ts +++ b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts @@ -4,6 +4,14 @@ */ /** 被弾報告の重複排除に利用するキーを生成する */ -export const createBombHitReportDedupeKey = (reporterSocketId: string, bombId: string): string => { +export const createBombHitReportDedupeKey = ( + reporterSocketId: string, + bombId: string, + targetPlayerId?: string, +): string => { + if (targetPlayerId) { + return `${bombId}:${targetPlayerId}`; + } + return `${reporterSocketId}:${bombId}`; };