diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index 582e7a3..f26326c 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -9,6 +9,7 @@ import { BombHitOrchestrator } from "@client/scenes/game/application/BombHitOrchestrator"; import { PlayerHitPolicy } from "@client/scenes/game/application/PlayerHitPolicy"; import { PlayerHitEffectOrchestrator } from "@client/scenes/game/application/PlayerHitEffectOrchestrator"; +import { RespawnManager } from "./RespawnManager"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; /** CombatLifecycleFacade の初期化入力 */ @@ -20,24 +21,16 @@ onLocalBombHitCountChanged: (count: number) => void; }; -type RespawnState = "idle" | "counting" | "pendingRespawn"; - /** 被弾関連ライフサイクルの制御を担当する */ export class CombatLifecycleFacade { - private readonly players: GamePlayers; private readonly myId: string; private readonly onSendBombHitReport: (bombId: string) => void; private readonly onLocalBombHitCountChanged: (count: number) => void; private readonly bombHitOrchestrator: BombHitOrchestrator; private readonly playerHitPolicy: PlayerHitPolicy; private readonly playerHitEffectOrchestrator: PlayerHitEffectOrchestrator; + private readonly respawnManager: RespawnManager; private localBombHitCount = 0; - private respawnState: RespawnState = "idle"; - private readonly hitCountByPlayerId = new Map(); - private readonly respawnTimersByPlayerId = new Map< - string, - ReturnType - >(); constructor({ players, @@ -46,7 +39,6 @@ onSendBombHitReport, onLocalBombHitCountChanged, }: CombatLifecycleFacadeOptions) { - this.players = players; this.myId = myId; this.onSendBombHitReport = onSendBombHitReport; this.onLocalBombHitCountChanged = onLocalBombHitCountChanged; @@ -64,14 +56,22 @@ blinkDurationMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_DURATION_MS, dedupWindowMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.DEDUP_WINDOW_MS, }); - this.hitCountByPlayerId.set(this.myId, 0); + this.respawnManager = new RespawnManager({ + players, + respawnStunMs: config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, + onRespawnComplete: (playerId) => { + if (playerId !== this.myId) return; + this.localBombHitCount = 0; + this.onLocalBombHitCountChanged(this.localBombHitCount); + }, + }); } /** 爆弾爆発時の判定と後続処理を実行する */ public handleBombExploded(payload: BombExplodedPayload): void { const hitPlayerId = this.bombHitOrchestrator.evaluateHit(payload); if (!hitPlayerId) return; - if (this.respawnState === "pendingRespawn") return; + if (this.respawnManager.isRespawning(this.myId)) return; const shouldStartRespawn = this.handleLocalBombHit(); this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); @@ -80,7 +80,7 @@ this.playerHitPolicy.applyLocalHitStun( config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, ); - this.startRespawnSequence(this.myId, true); + this.respawnManager.startSequence(this.myId); } else { this.playerHitPolicy.applyLocalHitStun(); } @@ -91,18 +91,16 @@ public handleNetworkPlayerHit(payload: PlayerHitPayload): void { this.playerHitPolicy.applyPlayerHitEvent(payload); - const shouldStartRespawn = - this.incrementHitCount(payload.playerId) >= - config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT; + if (this.respawnManager.isRespawning(payload.playerId)) return; + const hitCount = this.respawnManager.incrementHitCount(payload.playerId); this.playerHitEffectOrchestrator.handleNetworkPlayerHit( payload.playerId, this.myId, ); - if (shouldStartRespawn) { - this.startRespawnSequence(payload.playerId, false); - return; + if (hitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + this.respawnManager.startSequence(payload.playerId); } } @@ -110,10 +108,7 @@ public dispose(): void { this.bombHitOrchestrator.clear(); this.playerHitPolicy.dispose(); - this.respawnTimersByPlayerId.forEach((timerId) => { - clearTimeout(timerId); - }); - this.respawnTimersByPlayerId.clear(); + this.respawnManager.dispose(); } /** ローカル被弾回数を返す */ @@ -122,59 +117,10 @@ } private handleLocalBombHit(): boolean { - if (this.respawnState === "idle") { - this.respawnState = "counting"; - } - - this.localBombHitCount = this.incrementHitCount(this.myId); + this.localBombHitCount = this.respawnManager.incrementHitCount(this.myId); this.onLocalBombHitCountChanged(this.localBombHitCount); - - if (this.localBombHitCount < config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - return false; - } - - this.respawnState = "pendingRespawn"; - return true; - } - - private startRespawnSequence(playerId: string, isLocalPlayer: boolean): void { - const existingTimer = this.respawnTimersByPlayerId.get(playerId); - if (existingTimer) { - clearTimeout(existingTimer); - this.respawnTimersByPlayerId.delete(playerId); - } - - const target = this.players[playerId]; - if (target) { - target.setRespawnEffectVisible(true); - } - - const timerId = setTimeout(() => { - this.respawnTimersByPlayerId.delete(playerId); - - const player = this.players[playerId]; - if (player) { - player.respawnToInitialPosition(); - player.setRespawnEffectVisible(false); - } - - this.hitCountByPlayerId.set(playerId, 0); - - if (!isLocalPlayer) { - return; - } - - this.localBombHitCount = 0; - this.respawnState = "idle"; - this.onLocalBombHitCountChanged(this.localBombHitCount); - }, config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS); - - this.respawnTimersByPlayerId.set(playerId, timerId); - } - - private incrementHitCount(playerId: string): number { - const nextCount = (this.hitCountByPlayerId.get(playerId) ?? 0) + 1; - this.hitCountByPlayerId.set(playerId, nextCount); - return nextCount; + return ( + this.localBombHitCount >= config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT + ); } } diff --git a/apps/client/src/scenes/game/application/combat/RespawnManager.ts b/apps/client/src/scenes/game/application/combat/RespawnManager.ts new file mode 100644 index 0000000..415fcdd --- /dev/null +++ b/apps/client/src/scenes/game/application/combat/RespawnManager.ts @@ -0,0 +1,81 @@ +/** + * RespawnManager + * プレイヤーのリスポーンシーケンスと被弾カウントを管理する + * CombatLifecycleFacade からリスポーン責務を分離する + */ +import type { GamePlayers } from "@client/scenes/game/application/game.types"; + +/** RespawnManager の初期化入力 */ +export type RespawnManagerOptions = { + players: GamePlayers; + respawnStunMs: number; + onRespawnComplete: (playerId: string) => void; +}; + +/** リスポーンシーケンスと被弾カウントを管理する */ +export class RespawnManager { + private readonly players: GamePlayers; + private readonly respawnStunMs: number; + private readonly onRespawnComplete: (playerId: string) => void; + private readonly hitCountByPlayerId = new Map(); + private readonly respawnTimersByPlayerId = new Map< + string, + ReturnType + >(); + + constructor({ + players, + respawnStunMs, + onRespawnComplete, + }: RespawnManagerOptions) { + this.players = players; + this.respawnStunMs = respawnStunMs; + this.onRespawnComplete = onRespawnComplete; + } + + /** リスポーン待機中かどうかを返す */ + public isRespawning(playerId: string): boolean { + return this.respawnTimersByPlayerId.has(playerId); + } + + /** 被弾カウントを1増加して現在値を返す */ + public incrementHitCount(playerId: string): number { + const nextCount = (this.hitCountByPlayerId.get(playerId) ?? 0) + 1; + this.hitCountByPlayerId.set(playerId, nextCount); + return nextCount; + } + + /** + * リスポーンシーケンスを開始する + * すでにリスポーン中の場合は何もしない + */ + public startSequence(playerId: string): void { + if (this.isRespawning(playerId)) return; + + const target = this.players[playerId]; + if (target) { + target.setRespawnEffectVisible(true); + } + + const timerId = setTimeout(() => { + this.respawnTimersByPlayerId.delete(playerId); + + const player = this.players[playerId]; + if (player) { + player.respawnToInitialPosition(); + player.setRespawnEffectVisible(false); + } + + this.hitCountByPlayerId.set(playerId, 0); + this.onRespawnComplete(playerId); + }, this.respawnStunMs); + + this.respawnTimersByPlayerId.set(playerId, timerId); + } + + /** 管理中リソースを破棄する */ + public dispose(): void { + this.respawnTimersByPlayerId.forEach((timerId) => clearTimeout(timerId)); + this.respawnTimersByPlayerId.clear(); + } +} diff --git a/apps/client/src/scenes/game/entities/player/PlayerView.ts b/apps/client/src/scenes/game/entities/player/PlayerView.ts index c167ff6..8e6f8a5 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerView.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerView.ts @@ -6,13 +6,12 @@ import { Assets, Sprite, Texture } from "pixi.js"; import { config } from "@client/config"; import { Container, Text, TextStyle } from "pixi.js"; +import { loadRespawnEffectTexture } from "./RespawnEffectTextureCache"; const ENABLE_DEBUG_LOG = import.meta.env.DEV; export class PlayerView { public readonly displayObject: Container; - private static respawnEffectTexturePromise: Promise | null = null; - private static respawnEffectTexture: Texture | null = null; private readonly respawnEffectSprite: Sprite; private readonly sprite: Sprite; private readonly nameText: Text; @@ -25,6 +24,7 @@ this.respawnEffectSprite = new Sprite(Texture.WHITE); this.respawnEffectSprite.anchor.set(0.5, 0.5); this.respawnEffectSprite.visible = false; + this.applyRespawnEffectSize(); // 🌟 2. スプライト(画像)の生成(初期は1x1テクスチャ) this.sprite = new Sprite(Texture.WHITE); @@ -39,11 +39,6 @@ // ローカルプレイヤーだけ少し視認性を上げる(未使用引数対策を兼ねる) this.sprite.alpha = isLocal ? 1 : 0.95; - this.respawnEffectSprite.width = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; - this.respawnEffectSprite.height = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; - this.nameText = new Text({ text: playerName, style: new TextStyle({ @@ -71,32 +66,19 @@ private async applyRespawnEffectTexture(): Promise { try { const imageUrl = `${import.meta.env.BASE_URL}bakuhatueffe.svg`; - const texture = await PlayerView.loadRespawnEffectTexture(imageUrl); + const texture = await loadRespawnEffectTexture(imageUrl); this.respawnEffectSprite.texture = texture; - this.respawnEffectSprite.width = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; - this.respawnEffectSprite.height = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; + this.applyRespawnEffectSize(); } catch (error) { console.error("[PlayerView] bakuhatueffe.svg の読み込みに失敗", error); } } - /** リスポーン演出テクスチャを共有キャッシュ経由で取得する */ - private static async loadRespawnEffectTexture( - imageUrl: string, - ): Promise { - if (PlayerView.respawnEffectTexture) { - return PlayerView.respawnEffectTexture; - } - - if (!PlayerView.respawnEffectTexturePromise) { - PlayerView.respawnEffectTexturePromise = Assets.load(imageUrl); - } - - const loadedTexture = await PlayerView.respawnEffectTexturePromise; - PlayerView.respawnEffectTexture = loadedTexture; - return loadedTexture; + /** リスポーン演出スプライトに設定サイズを適用する */ + private applyRespawnEffectSize(): void { + const size = config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; + this.respawnEffectSprite.width = size; + this.respawnEffectSprite.height = size; } /** BASE_URL対応のURLで画像を読み込み、スプライトに反映する */ @@ -110,10 +92,6 @@ this.sprite.width = PLAYER_RADIUS_PX * 2 * PLAYER_RENDER_SCALE; this.sprite.height = PLAYER_RADIUS_PX * 2 * PLAYER_RENDER_SCALE; - this.respawnEffectSprite.width = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; - this.respawnEffectSprite.height = - config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; this.nameText.y = PLAYER_RADIUS_PX * PLAYER_RENDER_SCALE + 8; if (ENABLE_DEBUG_LOG) { diff --git a/apps/client/src/scenes/game/entities/player/RespawnEffectTextureCache.ts b/apps/client/src/scenes/game/entities/player/RespawnEffectTextureCache.ts new file mode 100644 index 0000000..ea0705a --- /dev/null +++ b/apps/client/src/scenes/game/entities/player/RespawnEffectTextureCache.ts @@ -0,0 +1,23 @@ +/** + * RespawnEffectTextureCache + * リスポーン演出テクスチャの共有キャッシュ + * 複数の PlayerView インスタンスからの重複ロードを防ぐ + */ +import { Assets, Texture } from "pixi.js"; + +let texturePromise: Promise | null = null; +let cachedTexture: Texture | null = null; + +/** リスポーン演出テクスチャをキャッシュ経由で取得する */ +export async function loadRespawnEffectTexture( + imageUrl: string, +): Promise { + if (cachedTexture) return cachedTexture; + + if (!texturePromise) { + texturePromise = Assets.load(imageUrl); + } + + cachedTexture = await texturePromise; + return cachedTexture; +}