diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 0cea6e5..1f419a2 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -12,6 +12,8 @@ import { GameTimer } from "./application/GameTimer"; import { GameNetworkSync } from "./application/GameNetworkSync"; import { GameLoop } from "./application/GameLoop"; +import { BombHitContextProvider } from "./application/BombHitContextProvider"; +import { BombHitOrchestrator } from "./application/BombHitOrchestrator"; import type { GamePlayers } from "./application/game.types"; /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ @@ -25,6 +27,7 @@ private timer = new GameTimer(); private appearanceResolver = new AppearanceResolver(); private bombManager: BombManager | null = null; + private bombHitOrchestrator: BombHitOrchestrator | null = null; private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; @@ -125,12 +128,23 @@ getJoystickInput: () => this.joystickInput, }); + const bombHitContextProvider = new BombHitContextProvider({ + players: this.players, + myId: this.myId, + }); + this.bombHitOrchestrator = new BombHitOrchestrator({ + contextProvider: bombHitContextProvider, + }); + this.bombManager = new BombManager({ worldContainer: this.worldContainer, players: this.players, myId: this.myId, getElapsedMs: () => this.timer.getElapsedMs(), appearanceResolver: this.appearanceResolver, + onBombExploded: (payload) => { + this.bombHitOrchestrator?.handleBombExploded(payload); + }, }); // サーバーへゲーム準備完了を通知 @@ -167,6 +181,8 @@ } this.bombManager?.destroy(); this.bombManager = null; + this.bombHitOrchestrator?.clear(); + this.bombHitOrchestrator = null; this.players = {}; this.isInputLocked = false; diff --git a/apps/client/src/scenes/game/application/BombHitContextProvider.ts b/apps/client/src/scenes/game/application/BombHitContextProvider.ts new file mode 100644 index 0000000..888c77e --- /dev/null +++ b/apps/client/src/scenes/game/application/BombHitContextProvider.ts @@ -0,0 +1,44 @@ +/** + * 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"; + +/** 判定用コンテキスト供給クラスの初期化入力 */ +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, + }; + } +} diff --git a/apps/client/src/scenes/game/application/BombHitOrchestrator.ts b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts new file mode 100644 index 0000000..1ff843d --- /dev/null +++ b/apps/client/src/scenes/game/application/BombHitOrchestrator.ts @@ -0,0 +1,63 @@ +/** + * BombHitOrchestrator + * 爆弾爆発イベントとローカルプレイヤー情報を橋渡しして当たり判定を実行する + * 同一爆弾の重複判定を抑止し,ヒット時のみデバッグログを出力する + */ +import { checkBombHit } from "@client/scenes/game/entities/bomb/BombHitDetector"; +import type { BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; +import { BombHitContextProvider } from "./BombHitContextProvider"; + +/** 当たり判定オーケストレーターの初期化入力 */ +type BombHitOrchestratorOptions = { + contextProvider: BombHitContextProvider; +}; + +/** 爆弾当たり判定の実行順序を制御する */ +export class BombHitOrchestrator { + private contextProvider: BombHitContextProvider; + private handledBombIds = new Set(); + + constructor({ contextProvider }: BombHitOrchestratorOptions) { + this.contextProvider = contextProvider; + } + + /** 爆弾爆発イベントを受けて当たり判定を実行する */ + public handleBombExploded(payload: BombExplodedPayload): void { + if (this.handledBombIds.has(payload.bombId)) { + return; + } + + this.handledBombIds.add(payload.bombId); + const localPlayer = this.contextProvider.getLocalPlayerCircle(); + if (!localPlayer) { + return; + } + + const result = checkBombHit({ + bomb: { + x: payload.x, + y: payload.y, + radius: payload.radius, + teamId: payload.teamId, + }, + player: localPlayer, + }); + + if (!result.isHit) { + return; + } + + console.log("[BombHitDebug] hit", { + bombId: payload.bombId, + distanceSquared: result.distanceSquared, + thresholdSquared: result.thresholdSquared, + bombTeamId: payload.teamId, + playerTeamId: localPlayer.teamId, + }); + } + + /** 判定済み状態を初期化する */ + public clear(): void { + this.handledBombIds.clear(); + } +} diff --git a/apps/client/src/scenes/game/entities/bomb/BombController.ts b/apps/client/src/scenes/game/entities/bomb/BombController.ts index 09552ab..8716ceb 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombController.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombController.ts @@ -5,6 +5,7 @@ */ import { BombModel } from "./BombModel"; import { BombView } from "./BombView"; +import type { BombState } from "./BombModel"; type BombControllerOptions = { x: number; @@ -38,6 +39,22 @@ this.view.renderState(this.model.getState(), this.model.getExplosionRadiusGrid(), this.model.getColor()); } + public getState(): BombState { + return this.model.getState(); + } + + public getPosition(): { x: number; y: number } { + return this.model.getPosition(); + } + + public getExplosionRadiusGrid(): number { + return this.model.getExplosionRadiusGrid(); + } + + public getTeamId(): number { + return this.model.getTeamId(); + } + public isFinished(): boolean { return this.model.isFinished(); } diff --git a/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts b/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts new file mode 100644 index 0000000..7bee698 --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/BombHitDetector.ts @@ -0,0 +1,49 @@ +/** + * 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/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index 3cdf04f..a9121bd 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -36,12 +36,22 @@ payload: PlaceBombPayload; }; +/** 爆弾爆発時に外部へ通知するペイロード型 */ +export type BombExplodedPayload = { + bombId: string; + x: number; + y: number; + radius: number; + teamId: number; +}; + type BombManagerOptions = { worldContainer: Container; players: GamePlayers; myId: string; getElapsedMs: ElapsedMsProvider; appearanceResolver: AppearanceResolver; + onBombExploded?: (payload: BombExplodedPayload) => void; }; /** 爆弾エンティティのライフサイクルを管理する */ @@ -56,13 +66,15 @@ private pendingBombRequestStore = new PendingBombRequestStore(); private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; private requestSerial = 0; + private onBombExploded?: (payload: BombExplodedPayload) => void; - constructor({ worldContainer, players, myId, getElapsedMs, appearanceResolver }: BombManagerOptions) { + constructor({ worldContainer, players, myId, getElapsedMs, appearanceResolver, onBombExploded }: BombManagerOptions) { this.worldContainer = worldContainer; this.players = players; this.myId = myId; this.getElapsedMs = getElapsedMs; this.appearanceResolver = appearanceResolver; + this.onBombExploded = onBombExploded; } /** 自プレイヤー位置に爆弾を仮IDで設置し,設置要求を返す */ @@ -157,8 +169,20 @@ const elapsedMs = this.getElapsedMs(); this.bombs.forEach((bomb, bombId) => { + const previousState = bomb.getState(); bomb.tick(elapsedMs); + if (previousState !== "exploded" && bomb.getState() === "exploded") { + const position = bomb.getPosition(); + this.onBombExploded?.({ + bombId, + x: position.x, + y: position.y, + radius: bomb.getExplosionRadiusGrid(), + teamId: bomb.getTeamId(), + }); + } + if (bomb.isFinished()) { this.removeBomb(bombId); return;