diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index a468fe4..410f186 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -110,8 +110,8 @@ players: this.players, myId: this.myId, acquireInputLock: this.lockInput.bind(this), - onSendBombHitReport: (bombId, targetPlayerId) => { - gameActionSender.sendBombHitReport(bombId, targetPlayerId); + onSendBombHitReport: (bombId) => { + gameActionSender.sendBombHitReport(bombId); }, }); this.runtime = new GameSceneRuntime({ diff --git a/apps/client/src/scenes/game/application/BombHitContextProvider.ts b/apps/client/src/scenes/game/application/BombHitContextProvider.ts deleted file mode 100644 index 584d88e..0000000 --- a/apps/client/src/scenes/game/application/BombHitContextProvider.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * BombHitContextProvider - * 爆弾当たり判定で利用するローカルプレイヤー情報を供給する - * プレイヤー管理構造から最小の判定DTOへ変換して返す - */ -import { config } from "@client/config"; -import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; -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; - myId: string; -}; - -/** 爆弾当たり判定に必要なローカルプレイヤー情報を返す */ -export class BombHitContextProvider { - private players: GamePlayers; - private myId: string; - - constructor({ players, myId }: BombHitContextProviderOptions) { - this.players = players; - this.myId = myId; - } - - /** ローカルプレイヤーの判定用DTOを取得する */ - public getLocalPlayerCircle(): TeamCollisionCircle | null { - const me = this.players[this.myId]; - if (!me || !(me instanceof LocalPlayerController)) { - return null; - } - - const position = me.getPosition(); - const snapshot = me.getSnapshot(); - - return { - x: position.x, - y: position.y, - radius: config.GAME_CONFIG.PLAYER_RADIUS, - 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 4cebadc..54f5231 100644 --- a/apps/client/src/scenes/game/application/BombHitOrchestrator.ts +++ b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts @@ -1,83 +1,78 @@ /** * BombHitOrchestrator - * 爆弾爆発イベントとローカルプレイヤー情報を橋渡しして当たり判定を実行する - * 判定結果を呼び出し元へ返して後続処理へ接続しやすくする + * 爆弾爆発イベントと自プレイヤー情報から当たり判定を実行する + * 自プレイヤーのみを判定対象とし,Bot被弾はサーバー側で処理する */ -import { checkBombHit } from "@client/scenes/game/entities/bomb/BombHitDetector"; +import { config } from "@client/config"; +import { domain } from "@repo/shared"; + +const { checkBombHit } = domain.game.bombHit; import type { BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; -import { BombHitContextProvider } from "./BombHitContextProvider"; +import type { GamePlayers } from "./game.types"; /** 当たり判定オーケストレーターの初期化入力 */ type BombHitOrchestratorOptions = { - contextProvider: BombHitContextProvider; -}; - -/** 爆弾爆発イベントの判定結果を表す型 */ -export type BombHitEvaluationResult = { - status: "duplicate" | "missing-local-player" | "no-hit" | "hit"; - hitPlayerIds: string[]; + players: GamePlayers; + myId: string; }; /** 爆弾当たり判定の実行順序を制御する */ export class BombHitOrchestrator { - private contextProvider: BombHitContextProvider; + private readonly players: GamePlayers; + private readonly myId: string; private handledBombIds = new Set(); - constructor({ contextProvider }: BombHitOrchestratorOptions) { - this.contextProvider = contextProvider; + constructor({ players, myId }: BombHitOrchestratorOptions) { + this.players = players; + this.myId = myId; } - /** 爆弾爆発イベントを受けて当たり判定を実行し結果を返す */ - public handleBombExploded(payload: BombExplodedPayload): BombHitEvaluationResult { + /** + * 爆弾爆発イベントを受けて自プレイヤーの当たり判定を実行する + * @returns 被弾した場合は自プレイヤーID、それ以外は null + */ + public evaluateHit(payload: BombExplodedPayload): string | null { if (this.handledBombIds.has(payload.bombId)) { - return { - status: "duplicate", - hitPlayerIds: [], - }; + return null; } this.handledBombIds.add(payload.bombId); - const localPlayer = this.contextProvider.getLocalPlayerCircle(); - if (!localPlayer) { - return { - status: "missing-local-player", - hitPlayerIds: [], - }; + const localCircle = this.getLocalPlayerCircle(); + if (!localCircle) { + return null; } - 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, - }); + const result = checkBombHit({ + bomb: { + x: payload.x, + y: payload.y, + radius: payload.radius, + teamId: payload.teamId, + }, + player: localCircle, + }); - return result.isHit; - }) - .map((player) => player.playerId); - - if (hitPlayerIds.length === 0) { - return { - status: "no-hit", - hitPlayerIds: [], - }; - } - - return { - status: "hit", - hitPlayerIds, - }; - + return result.isHit ? this.myId : null; } /** 判定済み状態を初期化する */ public clear(): void { this.handledBombIds.clear(); } + + /** 自プレイヤーの判定用円情報を取得する */ + private getLocalPlayerCircle() { + const me = this.players[this.myId]; + if (!me) return null; + + const position = me.getPosition(); + const snapshot = me.getSnapshot(); + + return { + x: position.x, + y: position.y, + radius: config.GAME_CONFIG.PLAYER_RADIUS, + teamId: snapshot.teamId, + }; + } } diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index 6597771..ad3586b 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -6,19 +6,17 @@ import { config } from "@client/config"; import type { PlayerDeadPayload } from "@repo/shared"; import type { BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; -import { BombHitContextProvider } from "@client/scenes/game/application/BombHitContextProvider"; import { BombHitOrchestrator } from "@client/scenes/game/application/BombHitOrchestrator"; import { PlayerDeathPolicy } from "@client/scenes/game/application/PlayerDeathPolicy"; import { PlayerHitEffectOrchestrator } from "@client/scenes/game/application/PlayerHitEffectOrchestrator"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; -import { HitReportPolicy } from "./HitReportPolicy"; /** CombatLifecycleFacade の初期化入力 */ export type CombatLifecycleFacadeOptions = { players: GamePlayers; myId: string; acquireInputLock: () => () => void; - onSendBombHitReport: (bombId: string, targetPlayerId: string) => void; + onSendBombHitReport: (bombId: string) => void; }; /** 被弾関連ライフサイクルの制御を担当する */ @@ -26,12 +24,10 @@ private readonly myId: string; private readonly onSendBombHitReport: ( bombId: string, - targetPlayerId: string, ) => void; private readonly bombHitOrchestrator: BombHitOrchestrator; private readonly playerDeathPolicy: PlayerDeathPolicy; private readonly playerHitEffectOrchestrator: PlayerHitEffectOrchestrator; - private readonly hitReportPolicy = new HitReportPolicy(); constructor({ players, @@ -42,10 +38,8 @@ this.myId = myId; this.onSendBombHitReport = onSendBombHitReport; this.bombHitOrchestrator = new BombHitOrchestrator({ - contextProvider: new BombHitContextProvider({ - players, - myId, - }), + players, + myId, }); this.playerDeathPolicy = new PlayerDeathPolicy({ myId, @@ -61,20 +55,12 @@ /** 爆弾爆発時の判定と後続処理を実行する */ public handleBombExploded(payload: BombExplodedPayload): void { - const result = this.bombHitOrchestrator.handleBombExploded(payload); - const hasLocalHit = result.hitPlayerIds.includes(this.myId); - if (hasLocalHit) { - this.playerDeathPolicy.applyLocalHitStun(); - this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); - } + const hitPlayerId = this.bombHitOrchestrator.evaluateHit(payload); + if (!hitPlayerId) return; - result.hitPlayerIds.forEach((targetPlayerId) => { - if (!this.hitReportPolicy.shouldSendReport(result.status, payload.bombId, targetPlayerId)) { - return; - } - - this.onSendBombHitReport(payload.bombId, targetPlayerId); - }); + this.playerDeathPolicy.applyLocalHitStun(); + this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); + this.onSendBombHitReport(payload.bombId); } /** ネットワーク被弾通知を適用する */ @@ -87,6 +73,5 @@ public dispose(): void { this.bombHitOrchestrator.clear(); this.playerDeathPolicy.dispose(); - this.hitReportPolicy.clear(); } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts b/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts deleted file mode 100644 index e65cd87..0000000 --- a/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * HitReportPolicy - * 爆弾被弾報告の送信可否を判定するポリシー - * 同一爆弾IDの重複送信を抑止する - */ - -/** 被弾判定結果の最小表現型 */ -export type HitEvaluationResult = "duplicate" | "missing-local-player" | "no-hit" | "hit"; - -/** 爆弾被弾報告の送信可否を管理する */ -export class HitReportPolicy { - private readonly reportedBombHitKeys = new Set(); - - /** 被弾報告を送信すべき場合に true を返す */ - public shouldSendReport( - result: HitEvaluationResult | undefined, - bombId: string, - targetPlayerId: string, - ): boolean { - if (result !== "hit") { - return false; - } - - const reportKey = `${bombId}:${targetPlayerId}`; - if (this.reportedBombHitKeys.has(reportKey)) { - return false; - } - - this.reportedBombHitKeys.add(reportKey); - return true; - } - - /** 判定済みIDをすべて破棄する */ - public clear(): void { - 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 358ec60..8c22eb2 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, targetPlayerId: string) => void; + sendBombHitReport: (bombId: string) => void; }; /** ソケット経由でゲーム中送信アクションを実行する実装 */ @@ -26,7 +26,7 @@ } /** 被弾報告をサーバーへ送信する */ - public sendBombHitReport(bombId: string, targetPlayerId: string): void { - socketManager.game.sendBombHitReport({ bombId, targetPlayerId }); + public sendBombHitReport(bombId: string): void { + socketManager.game.sendBombHitReport({ bombId }); } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts b/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts deleted file mode 100644 index 7bee698..0000000 --- a/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * BombHitDetector - * 爆弾とプレイヤーの円当たり判定を行う純関数を提供する - * 同チーム無効判定と二乗距離比較をまとめて扱う - */ - -/** 円当たり判定に利用する座標と半径の基本型 */ -export type CollisionCircle = { - x: number; - y: number; - radius: number; -}; - -/** チーム判定を伴う円当たり判定の入力型 */ -export type TeamCollisionCircle = CollisionCircle & { - teamId: number; -}; - -/** 爆弾当たり判定の入力型 */ -export type BombHitCheckInput = { - bomb: TeamCollisionCircle; - player: TeamCollisionCircle; -}; - -/** 爆弾当たり判定の結果型 */ -export type BombHitCheckResult = { - isHit: boolean; - isSameTeam: boolean; - distanceSquared: number; - thresholdSquared: number; -}; - -/** 爆弾とプレイヤーの当たり判定を実行する */ -export const checkBombHit = ({ bomb, player }: BombHitCheckInput): BombHitCheckResult => { - const isSameTeam = bomb.teamId === player.teamId; - const deltaX = bomb.x - player.x; - const deltaY = bomb.y - player.y; - const distanceSquared = deltaX * deltaX + deltaY * deltaY; - const sumRadius = bomb.radius + player.radius; - const thresholdSquared = sumRadius * sumRadius; - const isHit = !isSameTeam && distanceSquared < thresholdSquared; - - return { - isHit, - isSameTeam, - distanceSquared, - thresholdSquared, - }; -}; diff --git a/apps/client/src/scenes/game/entities/map/GameMapController.ts b/apps/client/src/scenes/game/entities/map/GameMapController.ts index 9faa689..ff0f999 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapController.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapController.ts @@ -30,13 +30,13 @@ } /** 全体マップ状態を反映する */ - public updateMapState(state: domain.gridMap.MapState): void { + public updateMapState(state: domain.game.gridMap.MapState): void { this.model.applyMapState(state); this.view.renderAll(this.resolveAllCellColors(this.model.getAllTeamIds())); } /** 差分セル更新を反映する */ - public updateCells(updates: domain.gridMap.CellUpdate[]): void { + public updateCells(updates: domain.game.gridMap.CellUpdate[]): void { this.model.applyUpdates(updates); updates.forEach(({ index }) => { diff --git a/apps/client/src/scenes/game/entities/map/GameMapModel.ts b/apps/client/src/scenes/game/entities/map/GameMapModel.ts index 2b1dd3d..c9ad37c 100644 --- a/apps/client/src/scenes/game/entities/map/GameMapModel.ts +++ b/apps/client/src/scenes/game/entities/map/GameMapModel.ts @@ -17,7 +17,7 @@ } /** 全体マップ状態を適用する */ - public applyMapState(state: domain.gridMap.MapState): void { + public applyMapState(state: domain.game.gridMap.MapState): void { const maxLength = Math.min( this.cellTeamIds.length, state.gridColors.length, @@ -28,7 +28,7 @@ } /** 差分セル更新を適用する */ - public applyUpdates(updates: domain.gridMap.CellUpdate[]): void { + public applyUpdates(updates: domain.game.gridMap.CellUpdate[]): void { updates.forEach(({ index, teamId }) => { if (!this.isValidIndex(index)) return; this.cellTeamIds[index] = teamId; diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts index fa4467c..470f039 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerController.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -18,7 +18,7 @@ }; /** リモート移動更新を表す型 */ -export type RemoteUpdate = Partial; +export type RemoteUpdate = Partial; /** * ローカル用コントローラーとリモート用コントローラーの共通基底 @@ -30,7 +30,7 @@ /** 共通初期化としてModelとViewを生成する */ protected constructor( - data: domain.player.PlayerData, + data: domain.game.player.PlayerData, isLocal: boolean, appearanceResolver: AppearanceResolver, ) { @@ -57,12 +57,12 @@ } /** 現在座標を取得する */ - public getPosition(): domain.player.MovePayload { + public getPosition(): domain.game.player.MovePayload { return this.model.getPosition(); } /** 外部送信用スナップショットを取得する */ - public getSnapshot(): domain.player.PlayerData { + public getSnapshot(): domain.game.player.PlayerData { return this.model.getSnapshot(); } @@ -82,7 +82,7 @@ export class LocalPlayerController extends BasePlayerController { /** ローカルプレイヤー用コントローラーを初期化する */ constructor( - data: domain.player.PlayerData, + data: domain.game.player.PlayerData, appearanceResolver: AppearanceResolver, ) { super(data, true, appearanceResolver); @@ -104,7 +104,7 @@ export class RemotePlayerController extends BasePlayerController { /** リモートプレイヤー用コントローラーを初期化する */ constructor( - data: domain.player.PlayerData, + data: domain.game.player.PlayerData, appearanceResolver: AppearanceResolver, ) { super(data, false, appearanceResolver); diff --git a/apps/client/src/scenes/game/entities/player/PlayerModel.ts b/apps/client/src/scenes/game/entities/player/PlayerModel.ts index 630f248..1825f8d 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerModel.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerModel.ts @@ -18,7 +18,7 @@ private targetGridY: number; /** 共有プレイヤー情報から初期状態を構築する */ - constructor(data: domain.player.PlayerData) { + constructor(data: domain.game.player.PlayerData) { this.id = data.id; this.name = data.name; this.teamId = data.teamId; @@ -29,12 +29,12 @@ } /** 現在座標を取得する */ - public getPosition(): domain.player.MovePayload { + public getPosition(): domain.game.player.MovePayload { return { x: this.gridX, y: this.gridY }; } /** 送信用スナップショットを取得する */ - public getSnapshot(): domain.player.PlayerData { + public getSnapshot(): domain.game.player.PlayerData { return { id: this.id, name: this.name, @@ -64,7 +64,7 @@ } /** リモート更新の目標座標を設定する */ - public setRemoteTarget(update: Partial): void { + public setRemoteTarget(update: Partial): void { if (update.x !== undefined && this.isFiniteNumber(update.x)) this.targetGridX = update.x; if (update.y !== undefined && this.isFiniteNumber(update.y)) diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 4b0e499..2f0c8cf 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -5,10 +5,11 @@ import type { domain, GameResultPayload, - PlaceBombPayload, } from "@repo/shared"; import { Player } from "./entities/player/Player.js"; import { GameRoomSession } from "./application/services/GameRoomSession"; +import type { GameSessionCallbacks } from "./application/services/GameRoomSession"; +import type { ActiveBombRegistration } from "./application/ports/gameUseCasePorts"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; import { GamePlayerOperationService } from "./application/services/GamePlayerOperationService"; @@ -66,16 +67,12 @@ startRoomSession( playerIds: string[], playerNamesById: Record, - onTick: (data: domain.game.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void, - onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + callbacks: GameSessionCallbacks, ) { this.lifecycleService.startRoomSession( playerIds, playerNamesById, - onTick, - onGameEnd, - onBotPlaceBomb, + callbacks, ); } @@ -99,9 +96,9 @@ return this.lifecycleService.issueServerBombId(); } - /** 指定プレイヤーがBotなら被弾硬直を適用する */ - applyBotHitStun(playerId: string, nowMs: number): boolean { - return this.lifecycleService.applyBotHitStun(playerId, nowMs); + /** 設置済み爆弾をアクティブレジストリに登録する */ + registerActiveBomb(registration: ActiveBombRegistration): void { + this.lifecycleService.registerActiveBomb(registration); } dispose(): void { diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 0e247fc..2c08386 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -17,22 +17,21 @@ UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; +import type { GameSessionCallbacks } from "../services/GameRoomSession"; /** ゲーム開始ユースケースが利用するゲーム管理入力ポート */ export interface StartGamePort { startRoomSession( playerIds: string[], playerNamesById: Record, - onTick: (data: domain.game.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void, - onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + callbacks: GameSessionCallbacks, ): void; getRoomStartTime(): number | undefined; } /** 準備完了ユースケースが利用するゲーム状態参照入力ポート */ export interface ReadyForGamePort { - getRoomPlayers(): domain.player.PlayerData[]; + getRoomPlayers(): domain.game.player.PlayerData[]; getRoomStartTime(): number | undefined; } @@ -75,8 +74,8 @@ ): void; } -/** 爆弾ユースケースが利用する送信出力ポート */ -export interface BombOutputPort { +/** 爆弾設置ユースケースが利用する送信出力ポート */ +export interface BombPlacementOutputPort { publishBombPlacedToOthersInRoom( roomId: domain.room.Room["roomId"], ownerSocketId: string, @@ -86,6 +85,10 @@ socketId: string, payload: BombPlacedAckPayload, ): void; +} + +/** プレイヤー死亡通知の送信出力ポート */ +export interface PlayerDeadOutputPort { publishPlayerDeadToOthersInRoom( roomId: domain.room.Room["roomId"], deadPlayerId: string, @@ -93,12 +96,6 @@ ): void; } -/** 爆弾設置ユースケースが利用する出力ポート */ -export type PlaceBombOutputPort = Pick< - BombOutputPort, - "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" ->; - /** start-game 系フローで利用する送信出力ポート */ export type StartGameOutputPort = Pick< GameOutputPort, @@ -108,27 +105,30 @@ | "publishGameResultToRoom" | "publishGameStartToRoom" > & - Pick< - BombOutputPort, - "publishBombPlacedToOthersInRoom" | "publishBombPlacedAckToSocket" - >; + BombPlacementOutputPort & + PlayerDeadOutputPort; /** 爆弾設置ユースケースが利用する爆弾状態入力ポート */ export interface BombPlacementPort { shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean; issueServerBombId(): string; + registerActiveBomb(registration: ActiveBombRegistration): void; } +/** registerActiveBomb に渡す爆弾登録情報 */ +export type ActiveBombRegistration = { + bombId: string; + ownerPlayerId: string; + x: number; + y: number; + explodeAtElapsedMs: number; +}; + /** 被弾報告ユースケースが利用する重複排除入力ポート */ export interface BombHitReportValidationPort { shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; } -/** 被弾報告ユースケースが利用するBot被弾反映入力ポート */ -export interface BotHitReactionPort { - applyBotHitStun(playerId: string, nowMs: number): boolean; -} - /** 爆弾設置ユースケースの入力値 */ export type PlaceBombInput = { socketId: string; @@ -142,9 +142,3 @@ payload: BombHitReportPayload; nowMs: number; }; - -/** 被弾報告ユースケースが利用する出力ポート */ -export type BombHitOutputPort = Pick< - BombOutputPort, - "publishPlayerDeadToOthersInRoom" ->; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index cd90ba7..f89c7da 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -8,9 +8,10 @@ logResults, logScopes, } from "@server/logging/index"; -import type { domain, GameResultPayload } from "@repo/shared"; +import type { domain, GameResultPayload, PlaceBombPayload } from "@repo/shared"; +import type { ActiveBombRegistration } from "../ports/gameUseCasePorts"; import { config } from "@server/config"; -import { GameLoop } from "../../loop/GameLoop"; +import { GameLoop, type GameLoopCallbacks } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; import { MapStore } from "../../entities/map/MapStore"; import { BombStateStore } from "../../entities/bomb/BombStateStore"; @@ -21,7 +22,14 @@ } from "../../entities/player/playerMovement.js"; import { buildGameResultPayload } from "./gameResultCalculator.js"; import { TeamAssignmentService } from "../services/TeamAssignmentService.js"; -import type { PlaceBombPayload } from "@repo/shared"; + +/** GameRoomSession のコールバック集合 */ +export type GameSessionCallbacks = { + onTick: (data: domain.game.tick.TickData) => void; + onGameEnd: (payload: GameResultPayload) => void; + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void; + onBotBombHit?: (targetPlayerId: string, bombId: string) => void; +}; /** ルーム単位のゲーム状態とループ進行を保持するセッションクラス */ export class GameRoomSession { @@ -57,9 +65,7 @@ public start( tickRate: number, - onTick: (data: domain.game.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void, - onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + callbacks: GameSessionCallbacks, ): void { if (this.gameLoop) { return; @@ -72,21 +78,28 @@ ).GAME_START_DELAY_MS; const startDelayMs = Math.max(0, gameStartDelayMs ?? 0); this.startTime = Date.now() + startDelayMs; - this.gameLoop = new GameLoop( - this.roomId, - tickRate, - this.players, - this.mapStore, - onTick, - () => { + + const loopCallbacks: GameLoopCallbacks = { + onTick: callbacks.onTick, + onGameEnd: () => { const resultPayload = buildGameResultPayload( this.mapStore.getGridColorsSnapshot(), ); this.dispose(); - onGameEnd(resultPayload); + callbacks.onGameEnd(resultPayload); }, - onBotPlaceBomb, - ); + onBotPlaceBomb: callbacks.onBotPlaceBomb, + onBotBombHit: callbacks.onBotBombHit, + }; + + this.gameLoop = new GameLoop({ + roomId: this.roomId, + tickRate, + players: this.players, + mapStore: this.mapStore, + activeBombRegistry: this.bombStateStore.activeBombRegistry, + callbacks: loopCallbacks, + }); if (startDelayMs === 0) { this.gameLoop.start(); @@ -176,13 +189,17 @@ return this.bombStateStore.issueServerBombId(); } - /** 指定プレイヤーがBotなら被弾硬直を適用する */ - public applyBotHitStun(playerId: string, nowMs: number): boolean { - if (!this.gameLoop) { - return false; - } - - return this.gameLoop.applyBotHitStun(playerId, nowMs); + /** 設置済み爆弾をアクティブレジストリに登録する */ + public registerActiveBomb(registration: ActiveBombRegistration): void { + const player = this.players.get(registration.ownerPlayerId); + const ownerTeamId = player?.teamId ?? -1; + this.bombStateStore.activeBombRegistry.registerBomb({ + bombId: registration.bombId, + x: registration.x, + y: registration.y, + explodeAtElapsedMs: registration.explodeAtElapsedMs, + ownerTeamId, + }); } public dispose(): void { diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index 10fcf2e..6a66cdc 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -3,10 +3,10 @@ * ゲームセッションの開始,参照,終了時クリーンアップを管理する */ import { config } from "@server/config"; +import type { ActiveBombRegistration } from "../ports/gameUseCasePorts"; import type { domain, GameResultPayload, - PlaceBombPayload, } from "@repo/shared"; import { logEvent } from "@server/logging/logger"; import { @@ -14,7 +14,7 @@ logResults, logScopes, } from "@server/logging/index"; -import { GameRoomSession } from "./GameRoomSession"; +import { GameRoomSession, type GameSessionCallbacks } from "./GameRoomSession"; type GameSessionRef = { current: GameRoomSession | null }; type ActivePlayerIndex = Set; @@ -61,22 +61,15 @@ 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 registerActiveBomb(registration: ActiveBombRegistration): void { + this.sessionRef.current?.registerActiveBomb(registration); } public startRoomSession( playerIds: string[], playerNamesById: Record, - onTick: (data: domain.game.TickData) => void, - onGameEnd: (payload: GameResultPayload) => void, - onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void, + callbacks: GameSessionCallbacks, ) { if (this.sessionRef.current) { logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { @@ -100,16 +93,14 @@ }); this.sessionRef.current = session; - session.start( - tickRate, - onTick, - (payload) => { + session.start(tickRate, { + ...callbacks, + onGameEnd: (payload) => { this.activePlayerIds.clear(); this.sessionRef.current = null; - onGameEnd(payload); + callbacks.onGameEnd(payload); }, - onBotPlaceBomb, - ); + }); logEvent(logScopes.GAME_SESSION_LIFECYCLE_SERVICE, { event: gameDomainLogEvents.SESSION_START, diff --git a/apps/server/src/domains/game/application/useCases/movePlayerUseCase.ts b/apps/server/src/domains/game/application/useCases/movePlayerUseCase.ts index 054fbfe..54bd04f 100644 --- a/apps/server/src/domains/game/application/useCases/movePlayerUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/movePlayerUseCase.ts @@ -8,7 +8,7 @@ type MovePlayerUseCaseParams = { gameManager: MovePlayerPort; playerId: string; - move: domain.player.MovePayload; + move: domain.game.player.MovePayload; }; /** プレイヤー移動入力をゲーム管理へ委譲する */ diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index 28c3017..073de20 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -4,7 +4,7 @@ */ import type { BombPlacementPort, - PlaceBombOutputPort, + BombPlacementOutputPort, PlaceBombInput, } from "../ports/gameUseCasePorts"; import { @@ -17,7 +17,7 @@ roomId: string; bombStore: BombPlacementPort; input: PlaceBombInput; - output: PlaceBombOutputPort; + output: BombPlacementOutputPort; }; /** 爆弾設置入力を重複排除と採番付きでルームへ配信する */ @@ -34,6 +34,14 @@ const bombId = bombStore.issueServerBombId(); + bombStore.registerActiveBomb({ + bombId, + ownerPlayerId: input.socketId, + x: input.payload.x, + y: input.payload.y, + explodeAtElapsedMs: input.payload.explodeAtElapsedMs, + }); + output.publishBombPlacedToOthersInRoom( roomId, input.socketId, diff --git a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts index cac9fbc..e47b9eb 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitUseCase.ts @@ -1,10 +1,10 @@ /** * reportBombHitUseCase * 被弾報告を受け取り,死亡通知の配信処理へ橋渡しする + * Bot被弾はサーバー側GameLoopで直接検知するため,自プレイヤーの報告のみ受け付ける */ import type { - BotHitReactionPort, - BombHitOutputPort, + PlayerDeadOutputPort, BombHitReportValidationPort, ReportBombHitInput, } from "../ports/gameUseCasePorts"; @@ -13,29 +13,14 @@ type ReportBombHitUseCaseParams = { roomId: string; validation: BombHitReportValidationPort; - botHitReaction: BotHitReactionPort; input: ReportBombHitInput; - output: BombHitOutputPort; -}; - -/** 被弾報告を死亡通知へ変換して配信する */ -const publishPlayerDeadFromBombHit = ( - roomId: string, - input: ReportBombHitInput, - output: BombHitOutputPort, -): void => { - const deadPlayerId = input.payload.targetPlayerId ?? input.socketId; - - output.publishPlayerDeadToOthersInRoom(roomId, deadPlayerId, { - playerId: deadPlayerId, - }); + output: PlayerDeadOutputPort; }; /** 被弾報告を受け取り,死亡通知を同一ルームへ配信する */ export const reportBombHitUseCase = ({ roomId, validation, - botHitReaction, input, output, }: ReportBombHitUseCaseParams): void => { @@ -43,10 +28,9 @@ return; } - const targetPlayerId = input.payload.targetPlayerId; - if (targetPlayerId) { - botHitReaction.applyBotHitStun(targetPlayerId, input.nowMs); - } + const deadPlayerId = input.socketId; - publishPlayerDeadFromBombHit(roomId, input, output); + 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 6437ea4..7dfd691 100644 --- a/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts +++ b/apps/server/src/domains/game/application/useCases/reportBombHitValidation.ts @@ -12,7 +12,6 @@ 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/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 1df8e88..c800c3c 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -55,41 +55,51 @@ output, }); + /** Bot被弾検出時にPLAYER_DEADをルームへ配信する */ + const handleBotBombHit = (targetPlayerId: string, _bombId: string): void => { + output.publishPlayerDeadToOthersInRoom(roomId, targetPlayerId, { + playerId: targetPlayerId, + }); + }; + gameSession.startRoomSession( playerIds, playerNamesById, - (tickData) => { - if (tickData.playerUpdates.length > 0) { - updateRecipients.forEach((playerId) => { - const updatesForPlayer = excludeRecipientFromPlayerUpdates( - tickData.playerUpdates, - playerId, - ); + { + onTick: (tickData) => { + if (tickData.playerUpdates.length > 0) { + updateRecipients.forEach((playerId) => { + const updatesForPlayer = excludeRecipientFromPlayerUpdates( + tickData.playerUpdates, + playerId, + ); - if (updatesForPlayer.length === 0) { - return; - } + if (updatesForPlayer.length === 0) { + return; + } - output.publishUpdatePlayersToSocket(playerId, updatesForPlayer); + output.publishUpdatePlayersToSocket(playerId, updatesForPlayer); + }); + } + + if (tickData.cellUpdates.length > 0) { + output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); + } + }, + onGameEnd: (resultPayload) => { + logEvent(logScopes.GAME_USE_CASE, { + event: gameUseCaseLogEvents.GAME_END, + result: logResults.EMITTED, + roomId, + reason: "duration_elapsed", }); - } - - if (tickData.cellUpdates.length > 0) { - output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); - } + output.publishGameEndToRoom(roomId); + output.publishGameResultToRoom(roomId, resultPayload); + onGameEnd(); + }, + onBotPlaceBomb: handleBotBombAction, + onBotBombHit: handleBotBombHit, }, - (resultPayload) => { - logEvent(logScopes.GAME_USE_CASE, { - event: gameUseCaseLogEvents.GAME_END, - result: logResults.EMITTED, - roomId, - reason: "duration_elapsed", - }); - output.publishGameEndToRoom(roomId); - output.publishGameResultToRoom(roomId, resultPayload); - onGameEnd(); - }, - handleBotBombAction, ); 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..19610bd 100644 --- a/apps/server/src/domains/game/entities/bomb/BombStateStore.ts +++ b/apps/server/src/domains/game/entities/bomb/BombStateStore.ts @@ -7,6 +7,7 @@ shouldBroadcastBombHitReport, shouldBroadcastBombPlaced, } from "./bombDedup.js"; +import { ActiveBombRegistry } from "./ActiveBombRegistry.js"; /** セッション単位の爆弾重複排除状態と採番状態を保持するストア */ export class BombStateStore { @@ -14,6 +15,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/entities/bomb/bombHitReport.ts b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts index 24478f8..5ca7406 100644 --- a/apps/server/src/domains/game/entities/bomb/bombHitReport.ts +++ b/apps/server/src/domains/game/entities/bomb/bombHitReport.ts @@ -7,11 +7,6 @@ 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/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index 3c9e35b..bf09972 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -12,7 +12,7 @@ // 全マスの現在の色(teamId)を保持 private gridColors: number[]; // 次回の送信ループで送る差分リスト - private pendingUpdates: domain.gridMap.CellUpdate[]; + private pendingUpdates: domain.game.gridMap.CellUpdate[]; constructor() { // 初期状態は -1 (無色) などで初期化 @@ -35,7 +35,7 @@ /** * 溜まっている差分を取得し,キューをクリアする(ループ送信時に使用) */ - public getAndClearUpdates(): domain.gridMap.CellUpdate[] { + public getAndClearUpdates(): domain.game.gridMap.CellUpdate[] { return drainPendingUpdates(this.pendingUpdates); } diff --git a/apps/server/src/domains/game/entities/map/mapPainting.ts b/apps/server/src/domains/game/entities/map/mapPainting.ts index efb054c..6135b8f 100644 --- a/apps/server/src/domains/game/entities/map/mapPainting.ts +++ b/apps/server/src/domains/game/entities/map/mapPainting.ts @@ -6,7 +6,7 @@ type PaintCellParams = { gridColors: number[]; - pendingUpdates: domain.gridMap.CellUpdate[]; + pendingUpdates: domain.game.gridMap.CellUpdate[]; index: number; teamId: number; }; diff --git a/apps/server/src/domains/game/entities/map/mapUpdates.ts b/apps/server/src/domains/game/entities/map/mapUpdates.ts index 2d56960..77ce63a 100644 --- a/apps/server/src/domains/game/entities/map/mapUpdates.ts +++ b/apps/server/src/domains/game/entities/map/mapUpdates.ts @@ -6,8 +6,8 @@ /** 差分キューを配列として返却し,キューを空にする */ export const drainPendingUpdates = ( - pendingUpdates: domain.gridMap.CellUpdate[] -): domain.gridMap.CellUpdate[] => { + pendingUpdates: domain.game.gridMap.CellUpdate[] +): domain.game.gridMap.CellUpdate[] => { const updates = [...pendingUpdates]; pendingUpdates.length = 0; return updates; diff --git a/apps/server/src/domains/game/entities/player/Player.ts b/apps/server/src/domains/game/entities/player/Player.ts index 48943b1..2c658c8 100644 --- a/apps/server/src/domains/game/entities/player/Player.ts +++ b/apps/server/src/domains/game/entities/player/Player.ts @@ -4,7 +4,7 @@ */ import { domain } from "@repo/shared"; -export class Player implements domain.player.PlayerData { +export class Player implements domain.game.player.PlayerData { public id: string; public name: string; public x: number = 0; diff --git a/apps/server/src/domains/game/entities/player/playerPosition.ts b/apps/server/src/domains/game/entities/player/playerPosition.ts index 700b0da..316e400 100644 --- a/apps/server/src/domains/game/entities/player/playerPosition.ts +++ b/apps/server/src/domains/game/entities/player/playerPosition.ts @@ -7,5 +7,5 @@ /** プレイヤー座標に対応するグリッドインデックスを返す */ export const getPlayerGridIndex = (player: Player): number | null => { - return domain.gridMap.getGridIndexFromPosition(player.x, player.y); + return domain.game.gridMap.getGridIndexFromPosition(player.x, player.y); }; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 403867b..e2471d1 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,27 @@ 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; + +/** GameLoop の初期化入力 */ +export type GameLoopOptions = { + roomId: string; + tickRate: number; + players: Map; + mapStore: MapStore; + activeBombRegistry: ActiveBombRegistry; + callbacks: GameLoopCallbacks; +}; + +/** GameLoop のコールバック集合 */ +export type GameLoopCallbacks = { + onTick: (data: domain.game.tick.TickData) => void; + onGameEnd: () => void; + onBotPlaceBomb?: (ownerId: string, payload: PlaceBombPayload) => void; + onBotBombHit?: (targetPlayerId: string, bombId: string) => void; +}; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { @@ -28,24 +50,27 @@ private endMonotonicTimeMs: number = 0; private nextTickAtMs: number = 0; private readonly maxCatchUpTicks: number = 3; - private lastSentPlayers: Map = + private lastSentPlayers: Map = new Map(); private disconnectedBotControlledPlayerIds: Set = new Set(); private botTurnOrchestrator: BotTurnOrchestrator = new BotTurnOrchestrator(); - constructor( - private roomId: string, - private tickRate: number, - private players: Map, - private mapStore: MapStore, - private onTick: (data: domain.game.TickData) => void, - private onGameEnd: () => void, - private onBotPlaceBomb?: ( - ownerId: string, - payload: PlaceBombPayload, - ) => void, - ) {} + private readonly roomId: string; + private readonly tickRate: number; + private readonly players: Map; + private readonly mapStore: MapStore; + private readonly activeBombRegistry: ActiveBombRegistry; + private readonly callbacks: GameLoopCallbacks; + + constructor(options: GameLoopOptions) { + this.roomId = options.roomId; + this.tickRate = options.tickRate; + this.players = options.players; + this.mapStore = options.mapStore; + this.activeBombRegistry = options.activeBombRegistry; + this.callbacks = options.callbacks; + } start() { // 既にループが回っている場合は何もしない @@ -84,7 +109,7 @@ let nowMs = performance.now(); if (nowMs >= this.endMonotonicTimeMs) { this.stop(); - this.onGameEnd(); + this.callbacks.onGameEnd(); return; } @@ -101,7 +126,7 @@ nowMs = performance.now(); if (nowMs >= this.endMonotonicTimeMs) { this.stop(); - this.onGameEnd(); + this.callbacks.onGameEnd(); return; } } @@ -122,8 +147,9 @@ ); const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); this.updateBotPlayers(wallClockNowMs, elapsedMs, gridColorsSnapshot); + this.detectBotBombHits(elapsedMs, wallClockNowMs); const tickData = this.buildTickData(); - this.onTick(tickData); + this.callbacks.onTick(tickData); } private updateBotPlayers( @@ -145,27 +171,53 @@ ); setPlayerPosition(player, decision.nextX, decision.nextY); - if (decision.placeBombPayload && this.onBotPlaceBomb) { - this.onBotPlaceBomb(player.id, decision.placeBombPayload); + if (decision.placeBombPayload && this.callbacks.onBotPlaceBomb) { + this.callbacks.onBotPlaceBomb(player.id, decision.placeBombPayload); } } }); } - /** 指定プレイヤーがBotなら被弾硬直を適用する */ - public applyBotHitStun(playerId: string, nowMs: number): boolean { - const player = this.players.get(playerId); - const isBotControlled = - !!player && - (isBotPlayerId(player.id) || - this.disconnectedBotControlledPlayerIds.has(player.id)); + /** 爆発済み爆弾とBotプレイヤーの当たり判定を実行する */ + private detectBotBombHits(elapsedMs: number, nowMs: number): void { + const onBotBombHit = this.callbacks.onBotBombHit; + if (!onBotBombHit) return; - if (!isBotControlled) { - return false; - } + const explodedBombs = + this.activeBombRegistry.collectExplodedBombs(elapsedMs); + if (explodedBombs.length === 0) return; - this.botTurnOrchestrator.applyHitStun(playerId as BotPlayerId, nowMs); - return true; + 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制御対象へ昇格する */ @@ -178,7 +230,7 @@ this.disconnectedBotControlledPlayerIds.delete(playerId); } - private buildTickData(): domain.game.TickData { + private buildTickData(): domain.game.tick.TickData { const activePlayerIds = new Set(); const playerUpdates = this.collectChangedPlayerUpdates(activePlayerIds); this.cleanupInactivePlayerSnapshots(activePlayerIds); @@ -191,8 +243,8 @@ private collectChangedPlayerUpdates( activePlayerIds: Set, - ): domain.game.TickData["playerUpdates"] { - const changedPlayers: domain.game.TickData["playerUpdates"] = []; + ): domain.game.tick.TickData["playerUpdates"] { + const changedPlayers: domain.game.tick.TickData["playerUpdates"] = []; this.players.forEach((player) => { activePlayerIds.add(player.id); @@ -202,7 +254,7 @@ } // 送信用のプレイヤーデータを構築 - const playerData: domain.game.PlayerPositionUpdate = { + const playerData: domain.game.tick.PlayerPositionUpdate = { id: player.id, x: player.x, y: player.y, diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index e0cbd1e..af90407 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -4,7 +4,6 @@ */ import { domain } from "@repo/shared"; import type { - BotHitReactionPort, BombHitReportValidationPort, BombPlacementPort, DisconnectPlayerPort, @@ -20,7 +19,6 @@ & MovePlayerPort & BombPlacementPort & BombHitReportValidationPort - & BotHitReactionPort & DisconnectPlayerPort; /** ルーム参加処理の実行結果 */ diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 530bbe1..f897f63 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -18,9 +18,11 @@ UpdatePlayersPayload, } from "@repo/shared"; import type { - BombOutputPort, + BombPlacementOutputPort, + PlayerDeadOutputPort, GameOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; +import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; import { sanitizeUpdatePlayersPayload } from "@server/network/adapters/gamePayloadSanitizers"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; @@ -32,7 +34,8 @@ GameOutputPort, "publishPlayerRemovedToRoom" > & - BombOutputPort; + BombPlacementOutputPort & + PlayerDeadOutputPort; /** ゲーム切断時の出力アダプターのインターフェース */ export type GameDisconnectOutputAdapter = Pick< @@ -89,7 +92,7 @@ ownerSocketId: string, payload: BombPlacedPayload, ) => { - if (ownerSocketId.startsWith("bot:")) { + if (isBotPlayerId(ownerSocketId)) { common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); return; } diff --git a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts index cffe09a..206ac49 100644 --- a/apps/server/src/network/handlers/game/gameEventOrchestrators.ts +++ b/apps/server/src/network/handlers/game/gameEventOrchestrators.ts @@ -84,7 +84,7 @@ /** MOVEイベントを調停して移動ユースケースを実行する */ export const handleMoveEvent = ( deps: GameEventOrchestratorDeps, - move: domain.player.MovePayload, + move: domain.game.player.MovePayload, ): void => { const resolved = runWithRuntimeByPlayerId( deps.roomManager, @@ -143,7 +143,6 @@ 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 3fbb775..b999b69 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -26,7 +26,7 @@ /** MOVEイベントのペイロードが移動座標であるか判定する */ export const isMovePayload = ( value: unknown, -): value is domain.player.MovePayload => { +): value is domain.game.player.MovePayload => { if (typeof value !== "object" || value === null) { return false; } @@ -51,12 +51,6 @@ } 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/domains/game/bombHit/bombHit.logic.ts b/packages/shared/src/domains/game/bombHit/bombHit.logic.ts new file mode 100644 index 0000000..54907b0 --- /dev/null +++ b/packages/shared/src/domains/game/bombHit/bombHit.logic.ts @@ -0,0 +1,27 @@ +/** + * bombHit.logic + * 爆弾とプレイヤーの円当たり判定を行う純関数を提供する + * 同チーム無効判定と二乗距離比較をまとめて扱う + */ +import type { BombHitCheckInput, BombHitCheckResult } from "./bombHit.type"; + +/** 爆弾とプレイヤーの当たり判定を実行する */ +export const checkBombHit = ({ + bomb, + player, +}: BombHitCheckInput): BombHitCheckResult => { + const isSameTeam = bomb.teamId === player.teamId; + const deltaX = bomb.x - player.x; + const deltaY = bomb.y - player.y; + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + const sumRadius = bomb.radius + player.radius; + const thresholdSquared = sumRadius * sumRadius; + const isHit = !isSameTeam && distanceSquared < thresholdSquared; + + return { + isHit, + isSameTeam, + distanceSquared, + thresholdSquared, + }; +}; diff --git a/packages/shared/src/domains/game/bombHit/bombHit.type.ts b/packages/shared/src/domains/game/bombHit/bombHit.type.ts new file mode 100644 index 0000000..6be0147 --- /dev/null +++ b/packages/shared/src/domains/game/bombHit/bombHit.type.ts @@ -0,0 +1,31 @@ +/** + * bombHit.type + * 爆弾当たり判定で利用する共有型を定義する + * 円同士の衝突判定とチーム判定に必要な型を提供する + */ + +/** 円当たり判定に利用する座標と半径の基本型 */ +export type CollisionCircle = { + x: number; + y: number; + radius: number; +}; + +/** チーム判定を伴う円当たり判定の入力型 */ +export type TeamCollisionCircle = CollisionCircle & { + teamId: number; +}; + +/** 爆弾当たり判定の入力型 */ +export type BombHitCheckInput = { + bomb: TeamCollisionCircle; + player: TeamCollisionCircle; +}; + +/** 爆弾当たり判定の結果型 */ +export type BombHitCheckResult = { + isHit: boolean; + isSameTeam: boolean; + distanceSquared: number; + thresholdSquared: number; +}; diff --git a/packages/shared/src/domains/game/bombHit/index.ts b/packages/shared/src/domains/game/bombHit/index.ts new file mode 100644 index 0000000..3a903bd --- /dev/null +++ b/packages/shared/src/domains/game/bombHit/index.ts @@ -0,0 +1,16 @@ +/** + * index + * bombHit サブドメインの公開要素を集約して再公開する + * 爆弾当たり判定の型とロジックを外部利用向けに束ねる + */ + +/** 爆弾当たり判定の型を再公開する */ +export type { + CollisionCircle, + TeamCollisionCircle, + BombHitCheckInput, + BombHitCheckResult, +} from "./bombHit.type"; + +/** 爆弾当たり判定ロジックを再公開する */ +export { checkBombHit } from "./bombHit.logic"; diff --git a/packages/shared/src/domains/game/game.type.ts b/packages/shared/src/domains/game/game.type.ts deleted file mode 100644 index 1f880a3..0000000 --- a/packages/shared/src/domains/game/game.type.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * game.type - * ゲーム進行で利用する共有型を定義する - */ -import type { CellUpdate } from "../gridMap/gridMap.type"; -import type { PlayerData } from "../player/player.type"; - -/** 1ティックで配信するプレイヤー座標差分 */ -export type PlayerPositionUpdate = Pick; - -/** 1ティック分のプレイヤー差分更新とマップ差分を表す共有データ */ -export interface TickData { - playerUpdates: PlayerPositionUpdate[]; - cellUpdates: CellUpdate[]; -} diff --git a/packages/shared/src/domains/game/gridMap/gridMap.logic.ts b/packages/shared/src/domains/game/gridMap/gridMap.logic.ts new file mode 100644 index 0000000..bb9d466 --- /dev/null +++ b/packages/shared/src/domains/game/gridMap/gridMap.logic.ts @@ -0,0 +1,20 @@ +import { GAME_CONFIG } from "../../../config/gameConfig"; + +/** + * グリッド座標から1次元配列インデックスを取得する(中心点判定) + */ +export function getGridIndexFromPosition(x: number, y: number): number | null { + const { GRID_COLS, GRID_ROWS } = GAME_CONFIG; + + // 座標がどのマス(列・行)に属するか計算 + const col = Math.floor(x); + const row = Math.floor(y); + + // マップ外の場合は null を返す + if (col < 0 || col >= GRID_COLS || row < 0 || row >= GRID_ROWS) { + return null; + } + + // 1次元配列のインデックスに変換 (row * 幅 + col) + return row * GRID_COLS + col; +} diff --git a/packages/shared/src/domains/game/gridMap/gridMap.type.ts b/packages/shared/src/domains/game/gridMap/gridMap.type.ts new file mode 100644 index 0000000..c59a2da --- /dev/null +++ b/packages/shared/src/domains/game/gridMap/gridMap.type.ts @@ -0,0 +1,16 @@ +/** + * gridMap.type + * グリッドマップ領域で利用する共有型を定義する + * マップ状態と差分更新の契約を集約する + */ + +/** マップ全体の色状態を保持する構造 */ +export interface MapState { + gridColors: number[]; +} + +/** マップ1セル分の差分更新情報 */ +export interface CellUpdate { + index: number; + teamId: number; +} diff --git a/packages/shared/src/domains/game/gridMap/index.ts b/packages/shared/src/domains/game/gridMap/index.ts new file mode 100644 index 0000000..cef8bbc --- /dev/null +++ b/packages/shared/src/domains/game/gridMap/index.ts @@ -0,0 +1,10 @@ +/** + * index + * gridMap サブドメインの公開要素を集約して再公開する + * 型定義と座標変換ロジックを外部利用向けに束ねる + */ + +/** グリッドマップ関連の型を再公開する */ +export type { MapState, CellUpdate } from "./gridMap.type"; +/** グリッド座標変換ロジックを再公開する */ +export { getGridIndexFromPosition } from "./gridMap.logic"; diff --git a/packages/shared/src/domains/game/index.ts b/packages/shared/src/domains/game/index.ts index 3b3cd6b..69ae808 100644 --- a/packages/shared/src/domains/game/index.ts +++ b/packages/shared/src/domains/game/index.ts @@ -1,8 +1,14 @@ /** * index * game ドメインの公開要素を集約して再公開する - * ゲーム進行で利用する型を外部利用向けに束ねる + * ゲーム進行配下のサブドメインを外部利用向けに束ねる */ -/** ゲーム進行関連の型を再公開する */ -export type { PlayerPositionUpdate, TickData } from "./game.type"; +/** tick同期サブドメインを再公開する */ +export * as tick from "./tick"; +/** プレイヤーサブドメインを再公開する */ +export * as player from "./player"; +/** グリッドマップサブドメインを再公開する */ +export * as gridMap from "./gridMap"; +/** 爆弾当たり判定サブドメインを再公開する */ +export * as bombHit from "./bombHit"; diff --git a/packages/shared/src/domains/game/player/index.ts b/packages/shared/src/domains/game/player/index.ts new file mode 100644 index 0000000..d302a12 --- /dev/null +++ b/packages/shared/src/domains/game/player/index.ts @@ -0,0 +1,8 @@ +/** + * index + * player サブドメインの公開要素を集約して再公開する + * プレイヤー契約で利用する型を外部利用向けに束ねる + */ + +/** プレイヤー契約関連の型を再公開する */ +export type { PlayerData, MovePayload } from "./player.type"; diff --git a/packages/shared/src/domains/game/player/player.type.ts b/packages/shared/src/domains/game/player/player.type.ts new file mode 100644 index 0000000..108052d --- /dev/null +++ b/packages/shared/src/domains/game/player/player.type.ts @@ -0,0 +1,22 @@ +/** + * player.type + * プレイヤー領域で利用する共有型を定義する + * クライアントとサーバーで参照する契約を集約する + */ + +/** クライアントとサーバー間で共有するプレイヤー基本情報 */ +export interface PlayerData { + id: string; + name: string; + // グリッド単位の座標 + x: number; + y: number; + teamId: number; // 0〜3 のチームID +} + +/** MOVE イベントで利用する移動入力ペイロード */ +export interface MovePayload { + // グリッド単位の座標 + x: number; + y: number; +} diff --git a/packages/shared/src/domains/game/tick/index.ts b/packages/shared/src/domains/game/tick/index.ts new file mode 100644 index 0000000..2b0417e --- /dev/null +++ b/packages/shared/src/domains/game/tick/index.ts @@ -0,0 +1,8 @@ +/** + * index + * tick サブドメインの公開要素を集約して再公開する + * tick同期で利用する型を外部利用向けに束ねる + */ + +/** tick同期関連の型を再公開する */ +export type { PlayerPositionUpdate, TickData } from "./tick.type"; diff --git a/packages/shared/src/domains/game/tick/tick.type.ts b/packages/shared/src/domains/game/tick/tick.type.ts new file mode 100644 index 0000000..38e0c63 --- /dev/null +++ b/packages/shared/src/domains/game/tick/tick.type.ts @@ -0,0 +1,15 @@ +/** + * tick.type + * ゲーム進行のtick同期で利用する共有型を定義する + */ +import type { CellUpdate } from "../gridMap/gridMap.type"; +import type { PlayerData } from "../player/player.type"; + +/** 1ティックで配信するプレイヤー座標差分 */ +export type PlayerPositionUpdate = Pick; + +/** 1ティック分のプレイヤー差分更新とマップ差分を表す共有データ */ +export interface TickData { + playerUpdates: PlayerPositionUpdate[]; + cellUpdates: CellUpdate[]; +} diff --git a/packages/shared/src/domains/gridMap/gridMap.logic.ts b/packages/shared/src/domains/gridMap/gridMap.logic.ts deleted file mode 100644 index 31a3ffe..0000000 --- a/packages/shared/src/domains/gridMap/gridMap.logic.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { GAME_CONFIG } from "../../config/gameConfig"; - -/** - * グリッド座標から1次元配列インデックスを取得する(中心点判定) - */ -export function getGridIndexFromPosition(x: number, y: number): number | null { - const { GRID_COLS, GRID_ROWS } = GAME_CONFIG; - - // 座標がどのマス(列・行)に属するか計算 - const col = Math.floor(x); - const row = Math.floor(y); - - // マップ外の場合は null を返す - if (col < 0 || col >= GRID_COLS || row < 0 || row >= GRID_ROWS) { - return null; - } - - // 1次元配列のインデックスに変換 (row * 幅 + col) - return row * GRID_COLS + col; -} \ No newline at end of file diff --git a/packages/shared/src/domains/gridMap/gridMap.type.ts b/packages/shared/src/domains/gridMap/gridMap.type.ts deleted file mode 100644 index 3e7811a..0000000 --- a/packages/shared/src/domains/gridMap/gridMap.type.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * gridMap.type - * グリッドマップ領域で利用する共有型を定義する - * マップ状態と差分更新の契約を集約する - */ - -/** マップ全体の色状態を保持する構造 */ -export interface MapState { - gridColors: number[]; -} - -/** マップ1セル分の差分更新情報 */ -export interface CellUpdate { - index: number; - teamId: number; -} \ No newline at end of file diff --git a/packages/shared/src/domains/gridMap/index.ts b/packages/shared/src/domains/gridMap/index.ts deleted file mode 100644 index 5daa48f..0000000 --- a/packages/shared/src/domains/gridMap/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * index - * gridMap ドメインの公開要素を集約して再公開する - * 型定義と座標変換ロジックを外部利用向けに束ねる - */ - -/** グリッドマップ関連の型を再公開する */ -export type { MapState, CellUpdate } from "./gridMap.type"; -/** グリッド座標変換ロジックを再公開する */ -export { getGridIndexFromPosition } from "./gridMap.logic"; diff --git a/packages/shared/src/domains/index.ts b/packages/shared/src/domains/index.ts index 15dcb80..bb788c0 100644 --- a/packages/shared/src/domains/index.ts +++ b/packages/shared/src/domains/index.ts @@ -8,9 +8,5 @@ export * as app from "./app"; /** game ドメインを再公開する */ export * as game from "./game"; -/** gridMap ドメインを再公開する */ -export * as gridMap from "./gridMap"; -/** player ドメインを再公開する */ -export * as player from "./player"; /** room ドメインを再公開する */ export * as room from "./room"; diff --git a/packages/shared/src/domains/player/index.ts b/packages/shared/src/domains/player/index.ts deleted file mode 100644 index 9ce56af..0000000 --- a/packages/shared/src/domains/player/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * index - * player ドメインの公開要素を集約して再公開する - * プレイヤー契約で利用する型を外部利用向けに束ねる - */ - -/** プレイヤー契約関連の型を再公開する */ -export type { PlayerData, MovePayload } from "./player.type"; diff --git a/packages/shared/src/domains/player/player.type.ts b/packages/shared/src/domains/player/player.type.ts deleted file mode 100644 index 108052d..0000000 --- a/packages/shared/src/domains/player/player.type.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * player.type - * プレイヤー領域で利用する共有型を定義する - * クライアントとサーバーで参照する契約を集約する - */ - -/** クライアントとサーバー間で共有するプレイヤー基本情報 */ -export interface PlayerData { - id: string; - name: string; - // グリッド単位の座標 - x: number; - y: number; - teamId: number; // 0〜3 のチームID -} - -/** MOVE イベントで利用する移動入力ペイロード */ -export interface MovePayload { - // グリッド単位の座標 - x: number; - y: number; -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4f69fcd..86c7bc4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,13 +10,13 @@ /** 既存互換のため,以下は従来どおり再公開する */ /** グリッドマップ関連の型定義を再公開 */ -export * as gridMapTypes from "./domains/gridMap/gridMap.type"; +export * as gridMapTypes from "./domains/game/gridMap/gridMap.type"; /** グリッドマップ関連のロジックを再公開 */ -export * as gridMapLogic from "./domains/gridMap/gridMap.logic"; +export * as gridMapLogic from "./domains/game/gridMap/gridMap.logic"; /** プレイヤー関連の型定義を再公開 */ -export * as playerTypes from "./domains/player/player.type"; +export * as playerTypes from "./domains/game/player/player.type"; /** ゲーム関連の型定義を再公開 */ -export * as gameTypes from "./domains/game/game.type"; +export * as gameTypes from "./domains/game/tick/tick.type"; /** アプリ状態関連の型定義を再公開 */ export * as appTypes from "./domains/app/app.type"; /** アプリ状態関連の定数を再公開 */ diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index d0f704d..7090983 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -3,12 +3,12 @@ * ゲーム進行イベントで利用するペイロード型を定義する * プレイヤー差分,マップ差分,開始終了系の契約を集約する */ -import type { PlayerPositionUpdate } from "../../domains/game/game.type"; -import type { CellUpdate } from "../../domains/gridMap/gridMap.type"; +import type { PlayerPositionUpdate } from "../../domains/game/tick/tick.type"; +import type { CellUpdate } from "../../domains/game/gridMap/gridMap.type"; import type { MovePayload as PlayerMovePayload, PlayerData, -} from "../../domains/player/player.type"; +} from "../../domains/game/player/player.type"; /** game-result イベントで送受信するランキング1行 */ export type GameResultRanking = { @@ -106,7 +106,6 @@ /** bomb-hit-report イベントで送受信する被弾報告 */ export type BombHitReportPayload = { bombId: string; - targetPlayerId?: string; }; /** player-dead イベントで送受信する死亡プレイヤー情報 */