diff --git a/apps/client/src/scenes/game/application/BombHitContextProvider.ts b/apps/client/src/scenes/game/application/BombHitContextProvider.ts index ce3a8c0..f79e43a 100644 --- a/apps/client/src/scenes/game/application/BombHitContextProvider.ts +++ b/apps/client/src/scenes/game/application/BombHitContextProvider.ts @@ -49,28 +49,22 @@ }; } - /** 被弾報告対象として扱うプレイヤー円情報を取得する */ + /** 被弾報告対象として自プレイヤーの円情報を取得する */ public getReportablePlayerCircles(): ReportablePlayerCircle[] { - const reportablePlayers = Object.entries(this.players).filter(([playerId]) => { - return playerId === this.myId || this.isBotPlayerId(playerId); - }); + const me = this.players[this.myId]; + if (!me) return []; - return reportablePlayers.map(([playerId, controller]) => { - const position = controller.getPosition(); - const snapshot = controller.getSnapshot(); + const position = me.getPosition(); + const snapshot = me.getSnapshot(); - return { - playerId, + return [ + { + playerId: this.myId, 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 822e51b..f9ad5bc 100644 --- a/apps/client/src/scenes/game/application/BombHitOrchestrator.ts +++ b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts @@ -1,7 +1,7 @@ /** * BombHitOrchestrator * 爆弾爆発イベントとローカルプレイヤー情報を橋渡しして当たり判定を実行する - * 判定結果を呼び出し元へ返して後続処理へ接続しやすくする + * 自プレイヤーのみを判定対象とし,Bot被弾はサーバー側で処理する */ import { domain } from "@repo/shared"; @@ -29,7 +29,7 @@ this.contextProvider = contextProvider; } - /** 爆弾爆発イベントを受けて当たり判定を実行し結果を返す */ + /** 爆弾爆発イベントを受けて自プレイヤーの当たり判定を実行し結果を返す */ public handleBombExploded(payload: BombExplodedPayload): BombHitEvaluationResult { if (this.handledBombIds.has(payload.bombId)) { return { @@ -39,15 +39,14 @@ } this.handledBombIds.add(payload.bombId); - const localPlayer = this.contextProvider.getLocalPlayerCircle(); - if (!localPlayer) { + const reportablePlayers = this.contextProvider.getReportablePlayerCircles(); + if (reportablePlayers.length === 0) { return { status: "missing-local-player", hitPlayerIds: [], }; } - const reportablePlayers = this.contextProvider.getReportablePlayerCircles(); const hitPlayerIds = reportablePlayers .filter((player) => { const result = checkBombHit({ diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 18a1c95..207db37 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -69,6 +69,7 @@ onTick: (data: domain.game.tick.TickData) => void, onGameEnd: (payload: GameResultPayload) => void, onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + onBotBombHit?: (targetPlayerId: string, bombId: string) => void, ) { this.lifecycleService.startRoomSession( playerIds, @@ -76,6 +77,7 @@ onTick, onGameEnd, onBotPlaceBomb, + onBotBombHit, ); } @@ -99,6 +101,23 @@ return this.lifecycleService.issueServerBombId(); } + /** 設置済み爆弾をアクティブレジストリに登録する */ + registerActiveBomb( + bombId: string, + ownerPlayerId: string, + x: number, + y: number, + explodeAtElapsedMs: number, + ): void { + this.lifecycleService.registerActiveBomb( + bombId, + ownerPlayerId, + x, + y, + explodeAtElapsedMs, + ); + } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ applyBotHitStun(playerId: string, nowMs: number): boolean { return this.lifecycleService.applyBotHitStun(playerId, nowMs); diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 7931dda..c1f0b72 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -26,6 +26,7 @@ onTick: (data: domain.game.tick.TickData) => void, onGameEnd: (payload: GameResultPayload) => void, onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + onBotBombHit?: (targetPlayerId: string, bombId: string) => void, ): void; getRoomStartTime(): number | undefined; } @@ -110,13 +111,22 @@ > & Pick< BombOutputPort, - "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" + | "publishBombPlacedToOthersInRoom" + | "publishBombPlacedAckToSocket" + | "publishPlayerDeadToOthersInRoom" >; /** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ export interface BombPlacementPort { shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean; issueServerBombId(): string; + registerActiveBomb( + bombId: string, + ownerPlayerId: string, + x: number, + y: number, + explodeAtElapsedMs: number, + ): void; } /** 被弾報告ユースケースが利用する重複排除入力ポート */ diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 44c1c0b..1868b9c 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -14,6 +14,7 @@ import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; import { BombStateStore } from "../../entities/bomb/BombStateStore"; +import { ActiveBombRegistry } from "../../entities/bomb/ActiveBombRegistry.js"; import { createSpawnedPlayer } from "../../entities/player/playerSpawn.js"; import { isValidPosition, @@ -28,6 +29,7 @@ private players: Map; private mapStore: MapStore; private bombStateStore: BombStateStore; + private activeBombRegistry: ActiveBombRegistry; private gameLoop: GameLoop | null = null; private startTime: number | undefined; private startDelayTimer: NodeJS.Timeout | null = null; @@ -40,6 +42,7 @@ this.players = new Map(); this.mapStore = new MapStore(); this.bombStateStore = new BombStateStore(); + this.activeBombRegistry = new ActiveBombRegistry(); playerIds.forEach((playerId) => { // 現在のプレイヤー構成から人数が最も少ないチームを算出する @@ -60,6 +63,7 @@ onTick: (data: domain.game.tick.TickData) => void, onGameEnd: (payload: GameResultPayload) => void, onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + onBotBombHit?: (targetPlayerId: string, bombId: string) => void, ): void { if (this.gameLoop) { return; @@ -77,6 +81,7 @@ tickRate, this.players, this.mapStore, + this.activeBombRegistry, onTick, () => { const resultPayload = buildGameResultPayload( @@ -86,6 +91,7 @@ onGameEnd(resultPayload); }, onBotPlaceBomb, + onBotBombHit, ); if (startDelayMs === 0) { @@ -176,6 +182,25 @@ return this.bombStateStore.issueServerBombId(); } + /** 設置済み爆弾をアクティブレジストリに登録する */ + public registerActiveBomb( + bombId: string, + ownerPlayerId: string, + x: number, + y: number, + explodeAtElapsedMs: number, + ): void { + const player = this.players.get(ownerPlayerId); + const ownerTeamId = player?.teamId ?? -1; + this.activeBombRegistry.registerBomb({ + bombId, + x, + y, + explodeAtElapsedMs, + ownerTeamId, + }); + } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ public applyBotHitStun(playerId: string, nowMs: number): boolean { if (!this.gameLoop) { @@ -195,6 +220,7 @@ this.gameLoop.stop(); this.gameLoop = null; } + this.activeBombRegistry.clear(); this.players.clear(); } } diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 3bf2800..3c5f54e 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -61,6 +61,23 @@ return session.issueServerBombId(); } + /** 設置済み爆弾をアクティブレジストリに登録する */ + public registerActiveBomb( + bombId: string, + ownerPlayerId: string, + x: number, + y: number, + explodeAtElapsedMs: number, + ): void { + this.sessionRef.current?.registerActiveBomb( + bombId, + ownerPlayerId, + x, + y, + explodeAtElapsedMs, + ); + } + /** 指定プレイヤーがBotなら被弾硬直を適用する */ public applyBotHitStun(playerId: string, nowMs: number): boolean { const session = this.sessionRef.current; @@ -77,6 +94,7 @@ onTick: (data: domain.game.tick.TickData) => void, onGameEnd: (payload: GameResultPayload) => void, onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + onBotBombHit?: (targetPlayerId: string, bombId: string) => void, ) { if (this.sessionRef.current) { logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { @@ -109,6 +127,7 @@ onGameEnd(payload); }, onBotPlaceBomb, + onBotBombHit, ); logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index 28c3017..a34671d 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -34,6 +34,14 @@ const bombId = bombStore.issueServerBombId(); + bombStore.registerActiveBomb( + bombId, + input.socketId, + input.payload.x, + input.payload.y, + input.payload.explodeAtElapsedMs, + ); + output.publishBombPlacedToOthersInRoom( roomId, input.socketId, diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 1df8e88..45b4a60 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -55,6 +55,13 @@ output, }); + /** Bot被弾検出時にPLAYER_DEADをルームへ配信する */ + const handleBotBombHit = (targetPlayerId: string, _bombId: string): void => { + output.publishPlayerDeadToOthersInRoom(roomId, targetPlayerId, { + playerId: targetPlayerId, + }); + }; + gameSession.startRoomSession( playerIds, playerNamesById, @@ -90,6 +97,7 @@ onGameEnd(); }, handleBotBombAction, + handleBotBombHit, ); const startTime = gameSession.getRoomStartTime() || Date.now(); diff --git a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts new file mode 100644 index 0000000..446b082 --- /dev/null +++ b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts @@ -0,0 +1,43 @@ +/** + * ActiveBombRegistry + * サーバー側で設置済み爆弾のライフサイクルを追跡する + * 爆発時刻に到達した爆弾を回収してBot被弾判定に利用する + */ + +/** アクティブ爆弾の状態表現 */ +export type ActiveBomb = { + bombId: string; + x: number; + y: number; + explodeAtElapsedMs: number; + ownerTeamId: number; +}; + +/** 設置済み爆弾を保持し爆発済みのものを回収するレジストリ */ +export class ActiveBombRegistry { + private bombs = new Map(); + + /** 新規爆弾を登録する */ + public registerBomb(bomb: ActiveBomb): void { + this.bombs.set(bomb.bombId, bomb); + } + + /** 爆発時刻に到達した爆弾を回収して返し,レジストリから除去する */ + public collectExplodedBombs(elapsedMs: number): ActiveBomb[] { + const exploded: ActiveBomb[] = []; + + this.bombs.forEach((bomb, bombId) => { + if (elapsedMs >= bomb.explodeAtElapsedMs) { + exploded.push(bomb); + this.bombs.delete(bombId); + } + }); + + return exploded; + } + + /** 登録済み爆弾をすべて破棄する */ + public clear(): void { + this.bombs.clear(); + } +} diff --git a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts index ecb645d..b314fe4 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -7,6 +7,10 @@ shouldBroadcastBombHitReport, shouldBroadcastBombPlaced, } from "./bombDedup.js"; +import { + ActiveBombRegistry, + type ActiveBomb, +} from "./ActiveBombRegistry.js"; /** セッション単位の爆弾重複排除状態と採番状態を保持するストア */ export class BombStateStore { @@ -14,6 +18,9 @@ private bombHitReportDedupTable = new Map(); private bombSerial = 0; + /** アクティブ爆弾のライフサイクルを追跡するレジストリ */ + public readonly activeBombRegistry = new ActiveBombRegistry(); + /** 爆弾設置イベントを配信すべきか判定し,配信時は重複排除状態を更新する */ public shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean { return shouldBroadcastBombPlaced({ diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 9e51f34..3bb9a10 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -6,7 +6,8 @@ import { MapStore } from "../entities/map/MapStore"; import { getPlayerGridIndex } from "../entities/player/playerPosition.js"; import { config } from "@server/config"; -import type { domain, PlaceBombPayload } from "@repo/shared"; +import { domain } from "@repo/shared"; +import type { PlaceBombPayload } from "@repo/shared"; import { logEvent } from "@server/logging/logger"; import { gameDomainLogEvents, @@ -19,6 +20,9 @@ type BotPlayerId, } from "../application/services/bot/index.js"; import { setPlayerPosition } from "../entities/player/playerMovement.js"; +import type { ActiveBombRegistry } from "../entities/bomb/ActiveBombRegistry.js"; + +const { checkBombHit } = domain.game.bombHit; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { @@ -39,12 +43,17 @@ private tickRate: number, private players: Map, private mapStore: MapStore, + private activeBombRegistry: ActiveBombRegistry, private onTick: (data: domain.game.tick.TickData) => void, private onGameEnd: () => void, private onBotPlaceBomb?: ( ownerId: string, payload: PlaceBombPayload, ) => void, + private onBotBombHit?: ( + targetPlayerId: string, + bombId: string, + ) => void, ) {} start() { @@ -122,6 +131,7 @@ ); const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); this.updateBotPlayers(wallClockNowMs, elapsedMs, gridColorsSnapshot); + this.detectBotBombHits(elapsedMs, wallClockNowMs); const tickData = this.buildTickData(); this.onTick(tickData); } @@ -168,6 +178,48 @@ return true; } + /** 爆発済み爆弾とBotプレイヤーの当たり判定を実行する */ + private detectBotBombHits(elapsedMs: number, nowMs: number): void { + const onBotBombHit = this.onBotBombHit; + if (!onBotBombHit) return; + + const explodedBombs = + this.activeBombRegistry.collectExplodedBombs(elapsedMs); + if (explodedBombs.length === 0) return; + + this.players.forEach((player) => { + const isBotControlled = + isBotPlayerId(player.id) || + this.disconnectedBotControlledPlayerIds.has(player.id); + if (!isBotControlled) return; + + for (const bomb of explodedBombs) { + const result = checkBombHit({ + bomb: { + x: bomb.x, + y: bomb.y, + radius: config.GAME_CONFIG.BOMB_RADIUS_GRID, + teamId: bomb.ownerTeamId, + }, + player: { + x: player.x, + y: player.y, + radius: config.GAME_CONFIG.PLAYER_RADIUS, + teamId: player.teamId, + }, + }); + + if (result.isHit) { + this.botTurnOrchestrator.applyHitStun( + player.id as BotPlayerId, + nowMs, + ); + onBotBombHit(player.id, bomb.bombId); + } + } + }); + } + /** 切断プレイヤーをBot制御対象へ昇格する */ public promotePlayerToBotControl(playerId: string): void { this.disconnectedBotControlledPlayerIds.add(playerId);