diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index bd62bf2..12d3d86 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -27,8 +27,7 @@ GameUiStateSyncService, type GameUiState, } from "./application/ui/GameUiStateSyncService"; -import { loadRespawnEffectTexture } from "./entities/player/RespawnEffectTextureCache"; -import { loadHurricaneTexture } from "./entities/hurricane/HurricaneTextureCache"; +import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -106,10 +105,7 @@ this.gameEventFacade = new GameEventFacade({ onGameStarted: (startTime) => { // ゲーム開始カウントダウン中に先読みして初回被弾時の負荷を抑える - void loadRespawnEffectTexture( - `${import.meta.env.BASE_URL}bakuhatueffe.svg`, - ); - void loadHurricaneTexture(`${import.meta.env.BASE_URL}hurricane.svg`); + preloadGameStartAssets(); this.sessionFacade.setGameStart(startTime); this.uiStateSyncService.emitIfChanged(); }, diff --git a/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts b/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts new file mode 100644 index 0000000..01f7f37 --- /dev/null +++ b/apps/client/src/scenes/game/application/assets/GameAssetPreloader.ts @@ -0,0 +1,13 @@ +/** + * GameAssetPreloader + * ゲーム開始前に必要な画像アセットの先読みを集約する + * 開始直後の描画負荷スパイクを抑える + */ +import { loadHurricaneTexture } from "@client/scenes/game/entities/hurricane/HurricaneTextureCache"; +import { loadRespawnEffectTexture } from "@client/scenes/game/entities/player/RespawnEffectTextureCache"; + +/** ゲーム開始前に必要なアセットを先読みする */ +export const preloadGameStartAssets = (): void => { + void loadRespawnEffectTexture(`${import.meta.env.BASE_URL}bakuhatueffe.svg`); + void loadHurricaneTexture(`${import.meta.env.BASE_URL}hurricane.svg`); +}; diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index 9d3a844..4b0c005 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -21,6 +21,8 @@ onLocalBombHitCountChanged: (count: number) => void; }; +type NetworkDamageSource = "bomb" | "hurricane"; + /** 被弾関連ライフサイクルの制御を担当する */ export class CombatLifecycleFacade { private readonly myId: string; @@ -89,51 +91,12 @@ /** ネットワーク被弾通知を適用する */ public handleNetworkPlayerHit(payload: PlayerHitPayload): void { - this.playerHitPolicy.applyPlayerHitEvent(payload); - - if (this.respawnManager.isRespawning(payload.playerId)) return; - - const hitCount = this.respawnManager.incrementHitCount(payload.playerId); - this.playerHitEffectOrchestrator.handleNetworkPlayerHit( - payload.playerId, - this.myId, - ); - - if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - this.respawnManager.startSequence(payload.playerId); - } + this.applyNetworkDamage(payload.playerId, "bomb"); } - /** ハリケーン被弾通知を適用する(リスポーンは行わずハートのみ減少する) */ + /** ハリケーン被弾通知を適用する */ public handleNetworkHurricaneHit(payload: HurricaneHitPayload): void { - if (this.respawnManager.isRespawning(payload.playerId)) return; - - const hitCount = this.respawnManager.incrementHitCount(payload.playerId); - - if (payload.playerId === this.myId) { - this.localBombHitCount = hitCount; - this.onLocalBombHitCountChanged(this.localBombHitCount); - this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); - } else { - this.playerHitEffectOrchestrator.handleNetworkPlayerHit( - payload.playerId, - this.myId, - ); - } - - if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - if (payload.playerId === this.myId) { - this.playerHitPolicy.applyLocalHitStun( - config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, - ); - } - this.respawnManager.startSequence(payload.playerId); - return; - } - - if (payload.playerId === this.myId) { - this.playerHitPolicy.applyLocalHitStun(); - } + this.applyNetworkDamage(payload.playerId, "hurricane"); } /** 管理中リソースを破棄する */ @@ -155,4 +118,47 @@ this.localBombHitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT ); } + + /** ネットワーク由来のダメージ適用を統一して実行する */ + private applyNetworkDamage( + targetPlayerId: string, + source: NetworkDamageSource, + ): void { + const isLocalTarget = targetPlayerId === this.myId; + + if (source === "bomb") { + this.playerHitPolicy.applyPlayerHitEvent({ playerId: targetPlayerId }); + } + + if (this.respawnManager.isRespawning(targetPlayerId)) { + return; + } + + const hitCount = this.respawnManager.incrementHitCount(targetPlayerId); + + if (isLocalTarget) { + this.localBombHitCount = hitCount; + this.onLocalBombHitCountChanged(this.localBombHitCount); + this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); + } else { + this.playerHitEffectOrchestrator.handleNetworkPlayerHit( + targetPlayerId, + this.myId, + ); + } + + if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + if (isLocalTarget) { + this.playerHitPolicy.applyLocalHitStun( + config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, + ); + } + this.respawnManager.startSequence(targetPlayerId); + return; + } + + if (source === "hurricane" && isLocalTarget) { + this.playerHitPolicy.applyLocalHitStun(); + } + } } diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 51c0aa4..4deec45 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -76,7 +76,42 @@ }); this.mapSyncHandler = new MapSyncHandler({ gameMap }); this.hurricaneSyncHandler = new HurricaneSyncHandler({ worldContainer }); - this.combatSyncHandler = new CombatSyncHandler({ + this.combatSyncHandler = this.createCombatSyncHandler({ + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerHit, + onRemoteHurricaneHit, + }); + this.onGameStarted = onGameStarted; + this.onGameEnded = onGameEnded; + this.onDebugLog = onDebugLog ?? (() => undefined); + this.receivedEventHandlers = this.createReceivedEventHandlers(); + } + + /** 受信イベント配信先ハンドラ群を返す */ + public getReceivedEventHandlers(): ReceivedGameEventHandlers { + return this.receivedEventHandlers; + } + + /** 状態反映層が保持するリソースを破棄する */ + public dispose(): void { + this.hurricaneSyncHandler.destroy(); + } + + /** 戦闘イベント橋渡しハンドラを生成する */ + private createCombatSyncHandler({ + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerHit, + onRemoteHurricaneHit, + }: Pick< + GameNetworkStateApplierOptions, + | "onRemoteBombPlaced" + | "onBombPlacementAcknowledged" + | "onRemotePlayerHit" + | "onRemoteHurricaneHit" + >): CombatSyncHandler { + return new CombatSyncHandler({ onRemoteBombPlaced: (payload) => { onRemoteBombPlaced(toRemoteBombPlacedPayload(payload)); }, @@ -92,10 +127,11 @@ onRemoteHurricaneHit(toRemoteHurricaneHitPayload(payload)); }, }); - this.onGameStarted = onGameStarted; - this.onGameEnded = onGameEnded; - this.onDebugLog = onDebugLog ?? (() => undefined); - this.receivedEventHandlers = { + } + + /** 受信イベントハンドラをまとめて生成する */ + private createReceivedEventHandlers(): ReceivedGameEventHandlers { + return { onReceivedCurrentPlayers: (payload) => { this.playerSyncHandler.handleCurrentPlayers(payload); }, @@ -143,14 +179,4 @@ }, }; } - - /** 受信イベント配信先ハンドラ群を返す */ - public getReceivedEventHandlers(): ReceivedGameEventHandlers { - return this.receivedEventHandlers; - } - - /** 状態反映層が保持するリソースを破棄する */ - public dispose(): void { - this.hurricaneSyncHandler.destroy(); - } } diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts index 83d1973..cc68f75 100644 --- a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts @@ -11,6 +11,7 @@ type HurricaneDisplay = { container: Container; sprite: Sprite; + radiusGrid: number; }; /** ハリケーン描画オーバーレイを管理する */ @@ -40,7 +41,10 @@ target = created; } - this.applySpriteSize(target.sprite, state.radius); + if (Math.abs(target.radiusGrid - state.radius) > 0.0001) { + this.applySpriteSize(target.sprite, state.radius); + target.radiusGrid = state.radius; + } target.container.x = state.x * config.GAME_CONFIG.GRID_CELL_SIZE; target.container.y = state.y * config.GAME_CONFIG.GRID_CELL_SIZE; @@ -72,6 +76,7 @@ const container = new Container(); const sprite = new Sprite(Texture.WHITE); sprite.anchor.set(0.5, 0.5); + this.applySpriteSize(sprite, 0); container.addChild(sprite); void this.applyTexture(sprite); @@ -79,6 +84,7 @@ return { container, sprite, + radiusGrid: 0, }; } @@ -94,8 +100,13 @@ /** 当たり判定半径に一致する見た目サイズを適用する */ private applySpriteSize(sprite: Sprite, radiusGrid: number): void { - const sizePx = radiusGrid * 2 * config.GAME_CONFIG.GRID_CELL_SIZE; + const sizePx = this.toSpriteSizePx(radiusGrid); sprite.width = sizePx; sprite.height = sizePx; } + + /** 半径グリッド値をスプライト直径ピクセルへ変換する */ + private toSpriteSizePx(radiusGrid: number): number { + return radiusGrid * 2 * config.GAME_CONFIG.GRID_CELL_SIZE; + } } diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index 65af49f..0cbfaad 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -26,6 +26,7 @@ } from "../application/services/bot/index.js"; import { setPlayerPosition } from "../entities/player/playerMovement.js"; import type { ActiveBombRegistry } from "../entities/bomb/ActiveBombRegistry.js"; +import { HurricaneSystem } from "./HurricaneSystem"; const { checkBombHit } = domain.game.bombHit; @@ -51,15 +52,7 @@ /** プレイヤーのグリッド位置キャッシュを含むエントリ */ type PlayerGridCacheEntry = PlayerGridEntry & { player: Player }; -type HurricaneState = { - id: string; - x: number; - y: number; - vx: number; - vy: number; - radius: number; - rotationRad: number; -}; +type DamageSource = "bomb" | "hurricane"; /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { @@ -74,9 +67,7 @@ private disconnectedBotControlledPlayerIds: Set = new Set(); private botTurnOrchestrator: BotTurnOrchestrator = new BotTurnOrchestrator(); private readonly botReceivedHitCountById = new Map(); - private hasSpawnedHurricanes = false; - private hurricanes: HurricaneState[] = []; - private readonly lastHurricaneHitAtMsByTargetId = new Map(); + private readonly hurricaneSystem = new HurricaneSystem(); private readonly roomId: string; private readonly tickRate: number; @@ -167,9 +158,9 @@ 0, Math.round(monotonicNowMs - this.startMonotonicTimeMs), ); - this.ensureHurricanesSpawned(elapsedMs); - this.updateHurricanes(this.tickRate / 1000); - this.detectHurricaneHits(wallClockNowMs, elapsedMs); + this.hurricaneSystem.ensureSpawned(elapsedMs); + this.hurricaneSystem.update(this.tickRate / 1000); + this.detectHurricaneHits(wallClockNowMs); const gridColorsSnapshot = this.mapStore.getGridColorsSnapshot(); this.updateBotPlayers(wallClockNowMs, elapsedMs, gridColorsSnapshot); this.detectBotBombHits(elapsedMs, wallClockNowMs); @@ -235,23 +226,7 @@ }); if (result.isHit) { - // 被弾カウントを更新し,閾値到達でリスポーンスタン,それ以外は通常スタンを適用する - const prevCount = this.botReceivedHitCountById.get(player.id) ?? 0; - const nextCount = prevCount + 1; - - if (nextCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - this.botReceivedHitCountById.set(player.id, 0); - this.botTurnOrchestrator.applyRespawnStun( - player.id as BotPlayerId, - nowMs, - ); - } else { - this.botReceivedHitCountById.set(player.id, nextCount); - this.botTurnOrchestrator.applyHitStun( - player.id as BotPlayerId, - nowMs, - ); - } + this.applyBotDamage(player.id, nowMs, "bomb"); // 爆弾所有者の bombHitCount を加算する const owner = this.players.get(bomb.ownerPlayerId); @@ -283,149 +258,47 @@ return { playerUpdates, cellUpdates: this.mapStore.getAndClearUpdates(), - hurricaneUpdates: this.hurricanes.map((hurricane) => ({ - id: hurricane.id, - x: hurricane.x, - y: hurricane.y, - radius: hurricane.radius, - rotationRad: hurricane.rotationRad, - })), + hurricaneUpdates: this.hurricaneSystem.getUpdatePayload(), }; } - /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ - private ensureHurricanesSpawned(elapsedMs: number): void { - if (!config.GAME_CONFIG.HURRICANE_ENABLED || this.hasSpawnedHurricanes) { - return; - } - - const remainingSec = - config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; - if (remainingSec > config.GAME_CONFIG.HURRICANE_SPAWN_REMAINING_SEC) { - return; - } - - this.hasSpawnedHurricanes = true; - this.hurricanes = Array.from( - { length: config.GAME_CONFIG.HURRICANE_COUNT }, - (_, index) => this.createHurricane(index), + /** ハリケーン接触を検知し,被弾通知を配信する */ + private detectHurricaneHits(nowMs: number): void { + const hitPlayerIds = this.hurricaneSystem.collectHitPlayerIds( + this.players, + nowMs, ); - } - /** ハリケーンを直線移動させ,境界で反射させる */ - private updateHurricanes(deltaSec: number): void { - if (this.hurricanes.length === 0) { - return; - } - - const maxX = config.GAME_CONFIG.GRID_COLS; - const maxY = config.GAME_CONFIG.GRID_ROWS; - - this.hurricanes.forEach((hurricane) => { - hurricane.x += hurricane.vx * deltaSec; - hurricane.y += hurricane.vy * deltaSec; - hurricane.rotationRad += - config.GAME_CONFIG.HURRICANE_VISUAL_ROTATION_SPEED * deltaSec; - - if (hurricane.x - hurricane.radius < 0) { - hurricane.x = hurricane.radius; - hurricane.vx *= -1; - } else if (hurricane.x + hurricane.radius > maxX) { - hurricane.x = maxX - hurricane.radius; - hurricane.vx *= -1; - } - - if (hurricane.y - hurricane.radius < 0) { - hurricane.y = hurricane.radius; - hurricane.vy *= -1; - } else if (hurricane.y + hurricane.radius > maxY) { - hurricane.y = maxY - hurricane.radius; - hurricane.vy *= -1; - } - }); - } - - /** ハリケーン接触を検知し,クールダウン付きで被弾通知を配信する */ - private detectHurricaneHits(nowMs: number, _elapsedMs: number): void { - if (this.hurricanes.length === 0) { - return; - } - - const hitCooldownMs = config.GAME_CONFIG.HURRICANE_HIT_COOLDOWN_MS; - - this.players.forEach((player) => { - const lastHitAtMs = this.lastHurricaneHitAtMsByTargetId.get(player.id); - if (lastHitAtMs !== undefined && nowMs - lastHitAtMs < hitCooldownMs) { - return; - } - - const isHit = this.hurricanes.some((hurricane) => { - const result = checkBombHit({ - bomb: { - x: hurricane.x, - y: hurricane.y, - radius: hurricane.radius, - teamId: -1, - }, - player: { - x: player.x, - y: player.y, - radius: config.GAME_CONFIG.PLAYER_RADIUS, - teamId: player.teamId, - }, - }); - - return result.isHit; - }); - - if (!isHit) { - return; - } - - this.lastHurricaneHitAtMsByTargetId.set(player.id, nowMs); - + hitPlayerIds.forEach((playerId) => { if ( - isBotPlayerId(player.id) || - this.disconnectedBotControlledPlayerIds.has(player.id) + isBotPlayerId(playerId) || + this.disconnectedBotControlledPlayerIds.has(playerId) ) { - const botPlayerId = player.id as BotPlayerId; - const prevCount = this.botReceivedHitCountById.get(player.id) ?? 0; - const nextCount = prevCount + 1; - - if (nextCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - this.botReceivedHitCountById.set(player.id, 0); - this.botTurnOrchestrator.applyRespawnStun(botPlayerId, nowMs); - } else { - this.botReceivedHitCountById.set(player.id, nextCount); - this.botTurnOrchestrator.applyHitStun(botPlayerId, nowMs); - } + this.applyBotDamage(playerId, nowMs, "hurricane"); } - this.callbacks.onHurricanePlayerHit?.(player.id); + this.callbacks.onHurricanePlayerHit?.(playerId); }); } - /** ハリケーン初期状態を生成する */ - private createHurricane(index: number): HurricaneState { - const radius = config.GAME_CONFIG.HURRICANE_DIAMETER_GRID / 2; - const x = this.randomInRange(radius, config.GAME_CONFIG.GRID_COLS - radius); - const y = this.randomInRange(radius, config.GAME_CONFIG.GRID_ROWS - radius); - const directionRad = this.randomInRange(0, Math.PI * 2); - const speed = config.GAME_CONFIG.HURRICANE_MOVE_SPEED; + /** Bot被弾時のスタン適用とカウント更新を行う */ + private applyBotDamage( + playerId: string, + nowMs: number, + _source: DamageSource, + ): void { + const botPlayerId = playerId as BotPlayerId; + const prevCount = this.botReceivedHitCountById.get(playerId) ?? 0; + const nextCount = prevCount + 1; - return { - id: `hurricane-${index + 1}`, - x, - y, - vx: Math.cos(directionRad) * speed, - vy: Math.sin(directionRad) * speed, - radius, - rotationRad: directionRad, - }; - } + if (nextCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + this.botReceivedHitCountById.set(playerId, 0); + this.botTurnOrchestrator.applyRespawnStun(botPlayerId, nowMs); + return; + } - private randomInRange(min: number, max: number): number { - return min + Math.random() * Math.max(0, max - min); + this.botReceivedHitCountById.set(playerId, nextCount); + this.botTurnOrchestrator.applyHitStun(botPlayerId, nowMs); } private collectChangedPlayerUpdates( @@ -501,9 +374,7 @@ this.botTurnOrchestrator.clear(); this.disconnectedBotControlledPlayerIds.clear(); this.lastSentPlayers.clear(); - this.hasSpawnedHurricanes = false; - this.hurricanes = []; - this.lastHurricaneHitAtMsByTargetId.clear(); + this.hurricaneSystem.clear(); if (this.loopId) { clearTimeout(this.loopId); diff --git a/apps/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts new file mode 100644 index 0000000..d959001 --- /dev/null +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -0,0 +1,168 @@ +/** + * HurricaneSystem + * ハリケーンの生成,移動,同期データ化,被弾検知を管理する + * GameLoop からハリケーン専用責務を分離する + */ +import { config } from "@server/config"; +import { domain, type HurricaneStatePayload } from "@repo/shared"; +import { Player } from "../entities/player/Player.js"; + +const { checkBombHit } = domain.game.bombHit; + +type HurricaneState = { + id: string; + x: number; + y: number; + vx: number; + vy: number; + radius: number; + rotationRad: number; +}; + +/** ハリケーン状態の生成更新と被弾判定を管理する */ +export class HurricaneSystem { + private hasSpawned = false; + private hurricanes: HurricaneState[] = []; + private readonly lastHitAtMsByPlayerId = new Map(); + + /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */ + public ensureSpawned(elapsedMs: number): void { + if (!config.GAME_CONFIG.HURRICANE_ENABLED || this.hasSpawned) { + return; + } + + const remainingSec = + config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000; + if (remainingSec > config.GAME_CONFIG.HURRICANE_SPAWN_REMAINING_SEC) { + return; + } + + this.hasSpawned = true; + this.hurricanes = Array.from( + { length: config.GAME_CONFIG.HURRICANE_COUNT }, + (_, index) => this.createHurricane(index), + ); + } + + /** ハリケーンを直線移動させ,境界で反射させる */ + public update(deltaSec: number): void { + if (this.hurricanes.length === 0) { + return; + } + + const maxX = config.GAME_CONFIG.GRID_COLS; + const maxY = config.GAME_CONFIG.GRID_ROWS; + + this.hurricanes.forEach((hurricane) => { + hurricane.x += hurricane.vx * deltaSec; + hurricane.y += hurricane.vy * deltaSec; + hurricane.rotationRad += + config.GAME_CONFIG.HURRICANE_VISUAL_ROTATION_SPEED * deltaSec; + + if (hurricane.x - hurricane.radius < 0) { + hurricane.x = hurricane.radius; + hurricane.vx *= -1; + } else if (hurricane.x + hurricane.radius > maxX) { + hurricane.x = maxX - hurricane.radius; + hurricane.vx *= -1; + } + + if (hurricane.y - hurricane.radius < 0) { + hurricane.y = hurricane.radius; + hurricane.vy *= -1; + } else if (hurricane.y + hurricane.radius > maxY) { + hurricane.y = maxY - hurricane.radius; + hurricane.vy *= -1; + } + }); + } + + /** 同期配信用のハリケーン状態配列を返す */ + public getUpdatePayload(): HurricaneStatePayload[] { + return this.hurricanes.map((hurricane) => ({ + id: hurricane.id, + x: hurricane.x, + y: hurricane.y, + radius: hurricane.radius, + rotationRad: hurricane.rotationRad, + })); + } + + /** クールダウン付きで被弾プレイヤーID配列を返す */ + public collectHitPlayerIds( + players: Map, + nowMs: number, + ): string[] { + if (this.hurricanes.length === 0) { + return []; + } + + const hitPlayerIds: string[] = []; + const hitCooldownMs = config.GAME_CONFIG.HURRICANE_HIT_COOLDOWN_MS; + + players.forEach((player) => { + const lastHitAtMs = this.lastHitAtMsByPlayerId.get(player.id); + if (lastHitAtMs !== undefined && nowMs - lastHitAtMs < hitCooldownMs) { + return; + } + + const isHit = this.hurricanes.some((hurricane) => { + const result = checkBombHit({ + bomb: { + x: hurricane.x, + y: hurricane.y, + radius: hurricane.radius, + teamId: -1, + }, + player: { + x: player.x, + y: player.y, + radius: config.GAME_CONFIG.PLAYER_RADIUS, + teamId: player.teamId, + }, + }); + + return result.isHit; + }); + + if (!isHit) { + return; + } + + this.lastHitAtMsByPlayerId.set(player.id, nowMs); + hitPlayerIds.push(player.id); + }); + + return hitPlayerIds; + } + + /** 状態を初期化する */ + public clear(): void { + this.hasSpawned = false; + this.hurricanes = []; + this.lastHitAtMsByPlayerId.clear(); + } + + /** ハリケーン初期状態を生成する */ + private createHurricane(index: number): HurricaneState { + const radius = config.GAME_CONFIG.HURRICANE_DIAMETER_GRID / 2; + const x = this.randomInRange(radius, config.GAME_CONFIG.GRID_COLS - radius); + const y = this.randomInRange(radius, config.GAME_CONFIG.GRID_ROWS - radius); + const directionRad = this.randomInRange(0, Math.PI * 2); + const speed = config.GAME_CONFIG.HURRICANE_MOVE_SPEED; + + return { + id: `hurricane-${index + 1}`, + x, + y, + vx: Math.cos(directionRad) * speed, + vy: Math.sin(directionRad) * speed, + radius, + rotationRad: directionRad, + }; + } + + private randomInRange(min: number, max: number): number { + return min + Math.random() * Math.max(0, max - min); + } +}