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/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index ce01adc..128278b 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -94,6 +94,11 @@ return this.lifecycleService.issueServerBombId(); } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ + applyBotHitStun(playerId: string, nowMs: number): boolean { + return this.lifecycleService.applyBotHitStun(playerId, nowMs); + } + dispose(): void { this.lifecycleService.dispose(); } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 73819f0..a5bd40d 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -123,6 +123,11 @@ shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; } +/** 被弾報告ユースケースが利用するBot被弾反映入力ポート */ +export interface BotHitReactionPort { + applyBotHitStun(playerId: string, nowMs: number): boolean; +} + /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index abbd935..6b52829 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -165,6 +165,15 @@ return this.bombStateStore.issueServerBombId(); } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ + public applyBotHitStun(playerId: string, nowMs: number): boolean { + if (!this.gameLoop) { + return false; + } + + return this.gameLoop.applyBotHitStun(playerId, nowMs); + } + public dispose(): void { if (this.startDelayTimer) { clearTimeout(this.startDelayTimer); diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index e5d05ec..10fcf2e 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -61,6 +61,16 @@ return session.issueServerBombId(); } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ + public applyBotHitStun(playerId: string, nowMs: number): boolean { + const session = this.sessionRef.current; + if (!session) { + return false; + } + + return session.applyBotHitStun(playerId, nowMs); + } + public startRoomSession( playerIds: string[], playerNamesById: Record, diff --git a/apps/server/src/domains/game/application/services/bot/combat/BotHitStunPolicy.ts b/apps/server/src/domains/game/application/services/bot/combat/BotHitStunPolicy.ts new file mode 100644 index 0000000..3bffbf0 --- /dev/null +++ b/apps/server/src/domains/game/application/services/bot/combat/BotHitStunPolicy.ts @@ -0,0 +1,29 @@ +/** + * BotHitStunPolicy + * Bot被弾硬直時間の判定と更新時刻計算を提供する + */ + +/** Bot被弾硬直ポリシーの初期化入力 */ +export type BotHitStunPolicyParams = { + hitStunMs: number; +}; + +/** Bot被弾硬直の判定と次回硬直終了時刻計算を提供する */ +export class BotHitStunPolicy { + private readonly hitStunMs: number; + + constructor({ hitStunMs }: BotHitStunPolicyParams) { + this.hitStunMs = hitStunMs; + } + + /** 現在時刻と硬直終了時刻から硬直中かどうかを判定する */ + public isStunned(nowMs: number, stunUntilMs: number): boolean { + return nowMs < stunUntilMs; + } + + /** 現在の硬直終了時刻と被弾時刻から次回硬直終了時刻を計算する */ + public calculateNextStunUntilMs(currentStunUntilMs: number, nowMs: number): number { + const candidateStunUntilMs = nowMs + this.hitStunMs; + return Math.max(currentStunUntilMs, candidateStunUntilMs); + } +} diff --git a/apps/server/src/domains/game/application/services/bot/index.ts b/apps/server/src/domains/game/application/services/bot/index.ts index 94bd860..9964fa4 100644 --- a/apps/server/src/domains/game/application/services/bot/index.ts +++ b/apps/server/src/domains/game/application/services/bot/index.ts @@ -17,3 +17,6 @@ /** Bot爆弾アクションハンドラ生成関数を再公開 */ export { createBotBombActionHandler } from "./adapters/BotBombActionAdapter.js"; + +/** Bot被弾硬直ポリシーを再公開 */ +export { BotHitStunPolicy } from "./combat/BotHitStunPolicy.js"; diff --git a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts index aa432dd..cf2fb6b 100644 --- a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts +++ b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts @@ -8,6 +8,7 @@ import { moveTowardsTarget } from "../movement/MovePlanner.js"; import { chooseNextTarget } from "../policies/TargetSelectionPolicy.js"; import { decideBombPlacement } from "../policies/BombPlacementPolicy.js"; +import { BotHitStunPolicy } from "../combat/BotHitStunPolicy.js"; import { BotStateStore } from "../state/BotStateStore.js"; import type { BotDecision } from "../types/BotTypes.js"; @@ -18,6 +19,9 @@ /** Botの1tick分の意思決定を提供するオーケストレータ */ export class BotTurnOrchestrator { private stateStore = new BotStateStore(); + private readonly hitStunPolicy = new BotHitStunPolicy({ + hitStunMs: config.GAME_CONFIG.PLAYER_HIT_STUN_MS, + }); public decide( botPlayerId: BotPlayerId, @@ -35,8 +39,21 @@ targetRow: currentRow, lastBombPlacedAtMs: Number.NEGATIVE_INFINITY, bombSeq: 0, + stunUntilMs: Number.NEGATIVE_INFINITY, }); + if (this.hitStunPolicy.isStunned(nowMs, currentState.stunUntilMs)) { + this.stateStore.set(botPlayerId, { + ...currentState, + }); + + return { + nextX: player.x, + nextY: player.y, + placeBombPayload: null, + }; + } + const targetCenterX = currentState.targetCol + 0.5; const targetCenterY = currentState.targetRow + 0.5; const reachedTarget = @@ -69,6 +86,7 @@ targetRow: nextTarget.row, bombSeq: bombDecision.nextBombSeq, lastBombPlacedAtMs: bombDecision.nextLastBombPlacedAtMs, + stunUntilMs: currentState.stunUntilMs, }); return { @@ -78,6 +96,19 @@ }; } + /** 指定Botへ被弾硬直を適用する */ + public applyHitStun(botPlayerId: BotPlayerId, nowMs: number): void { + this.stateStore.update(botPlayerId, (state) => { + return { + ...state, + stunUntilMs: this.hitStunPolicy.calculateNextStunUntilMs( + state.stunUntilMs, + nowMs, + ), + }; + }); + } + public clear(): void { this.stateStore.clear(); } diff --git a/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts b/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts index 802f4cf..17abae5 100644 --- a/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts +++ b/apps/server/src/domains/game/application/services/bot/state/BotStateStore.ts @@ -17,6 +17,19 @@ this.states.set(botPlayerId, state); } + /** 既存状態がある場合のみ更新関数を適用する */ + public update( + botPlayerId: BotPlayerId, + updater: (state: BotState) => BotState, + ): void { + const current = this.states.get(botPlayerId); + if (!current) { + return; + } + + this.states.set(botPlayerId, updater(current)); + } + public clear(): void { this.states.clear(); } diff --git a/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts b/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts index fb3ebf3..8e95cd0 100644 --- a/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts +++ b/apps/server/src/domains/game/application/services/bot/types/BotTypes.ts @@ -10,6 +10,7 @@ targetRow: number; lastBombPlacedAtMs: number; bombSeq: number; + stunUntilMs: number; }; /** 移動先のグリッド座標 */ diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index 5bbe2e4..927f8ba 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -3,6 +3,7 @@ * 被弾報告を受け取り,死亡通知の配信処理へ橋渡しする */ import type { + BotHitReactionPort, BombHitOutputPort, BombHitReportValidationPort, ReportBombHitInput, @@ -12,6 +13,7 @@ type ReportBombHitUseCaseParams = { roomId: string; validation: BombHitReportValidationPort; + botHitReaction: BotHitReactionPort; input: ReportBombHitInput; output: BombHitOutputPort; }; @@ -22,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, }); }; @@ -31,6 +35,7 @@ export const reportBombHitUseCase = ({ roomId, validation, + botHitReaction, input, output, }: ReportBombHitUseCaseParams): void => { @@ -38,5 +43,10 @@ return; } + const targetPlayerId = input.payload.targetPlayerId; + if (targetPlayerId && botHitReaction.applyBotHitStun(targetPlayerId, input.nowMs)) { + return; + } + publishPlayerDeadFromBombHit(roomId, input, output); }; 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}`; }; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 9bde2a1..633959d 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -143,6 +143,17 @@ }); } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ + public applyBotHitStun(playerId: string, nowMs: number): boolean { + const player = this.players.get(playerId); + if (!player || !isBotPlayerId(player.id)) { + return false; + } + + this.botTurnOrchestrator.applyHitStun(player.id, nowMs); + return true; + } + private buildTickData(): domain.game.TickData { const activePlayerIds = new Set(); const playerUpdates = this.collectChangedPlayerUpdates(activePlayerIds); diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index 3cbf6d3..8826220 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 { domain } from "@repo/shared"; import type { + BotHitReactionPort, BombHitReportValidationPort, BombPlacementPort, DisconnectPlayerPort, @@ -19,6 +20,7 @@ & MovePlayerPort & BombPlacementPort & BombHitReportValidationPort + & BotHitReactionPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts index 874cdf2..cffe09a 100644 --- a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts +++ b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts @@ -143,6 +143,7 @@ reportBombHitUseCase({ roomId, validation: gameManager, + botHitReaction: gameManager, input: { socketId: deps.socketId, payload, diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index 39941ea..3fbb775 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -51,6 +51,12 @@ } const candidate = value as Record; + const targetPlayerId = candidate.targetPlayerId; + + if (targetPlayerId !== undefined && !isNonEmptyString(targetPlayerId)) { + return false; + } + return isNonEmptyString(candidate.bombId); }; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 6e1d6b2..22c2b64 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -99,6 +99,7 @@ /** bomb-hit-report イベントで送受信する被弾報告 */ export type BombHitReportPayload = { bombId: string; + targetPlayerId?: string; }; /** player-dead イベントで送受信する死亡プレイヤー情報 */