diff --git a/apps/client/public/bakuhatueffe.svg b/apps/client/public/bakuhatueffe.svg new file mode 100644 index 0000000..ead414b --- /dev/null +++ b/apps/client/public/bakuhatueffe.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index 20324f2..fa3f978 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -8,6 +8,14 @@ (sharedConfig.GAME_CONFIG as { PLAYER_RESPAWN_HIT_COUNT?: number }) .PLAYER_RESPAWN_HIT_COUNT ?? 5; +const sharedRespawnStunMs = + (sharedConfig.GAME_CONFIG as { PLAYER_RESPAWN_STUN_MS?: number }) + .PLAYER_RESPAWN_STUN_MS ?? sharedConfig.GAME_CONFIG.PLAYER_HIT_STUN_MS; + +const sharedRespawnEffectScale = + (sharedConfig.GAME_CONFIG as { PLAYER_RESPAWN_EFFECT_SCALE?: number }) + .PLAYER_RESPAWN_EFFECT_SCALE ?? 2.4; + const CLIENT_GAME_CONFIG = { TIMER_DISPLAY_UPDATE_MS: 250, JOIN_REQUEST_TIMEOUT_MS: 8000, @@ -15,6 +23,8 @@ FRAME_DELTA_MAX_MS: 50, PLAYER_RESPAWN_HIT_COUNT: sharedRespawnHitCount, + PLAYER_RESPAWN_STUN_MS: sharedRespawnStunMs, + PLAYER_RESPAWN_EFFECT_SCALE: sharedRespawnEffectScale, PLAYER_LERP_SMOOTHNESS: 18, PLAYER_LERP_SNAP_THRESHOLD: 0.005, @@ -50,6 +60,9 @@ get BOMB_RENDER_RADIUS_PX(): number { return this.GRID_CELL_SIZE * 0.2 * sharedBombRenderScale; }, + get PLAYER_RESPAWN_EFFECT_SIZE_PX(): number { + return this.PLAYER_RADIUS_PX * 2 * this.PLAYER_RESPAWN_EFFECT_SCALE; + }, } as const; const NETWORK_CONFIG = { diff --git a/apps/client/src/scenes/game/application/PlayerHitPolicy.ts b/apps/client/src/scenes/game/application/PlayerHitPolicy.ts index 04b907e..2b9f36f 100644 --- a/apps/client/src/scenes/game/application/PlayerHitPolicy.ts +++ b/apps/client/src/scenes/game/application/PlayerHitPolicy.ts @@ -30,8 +30,8 @@ } /** ローカル被弾判定時に硬直を適用する */ - public applyLocalHitStun(): void { - this.applyHitStun(); + public applyLocalHitStun(durationMs?: number): void { + this.applyHitStun(durationMs); } /** ポリシーが保持するタイマーと入力ロックを解放する */ @@ -47,7 +47,7 @@ } } - private applyHitStun(): void { + private applyHitStun(durationMs = this.hitStunMs): void { if (!this.activeLockRelease) { this.activeLockRelease = this.acquireInputLock(); } @@ -63,6 +63,6 @@ this.activeLockRelease(); this.activeLockRelease = null; } - }, this.hitStunMs); + }, durationMs); } } diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index f5058e1..582e7a3 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -33,7 +33,11 @@ private readonly playerHitEffectOrchestrator: PlayerHitEffectOrchestrator; private localBombHitCount = 0; private respawnState: RespawnState = "idle"; - private respawnTimer: ReturnType | null = null; + private readonly hitCountByPlayerId = new Map(); + private readonly respawnTimersByPlayerId = new Map< + string, + ReturnType + >(); constructor({ players, @@ -60,6 +64,7 @@ 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); } /** 爆弾爆発時の判定と後続処理を実行する */ @@ -68,29 +73,47 @@ if (!hitPlayerId) return; if (this.respawnState === "pendingRespawn") return; - this.handleLocalBombHit(); - this.playerHitPolicy.applyLocalHitStun(); + const shouldStartRespawn = this.handleLocalBombHit(); this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); + + if (shouldStartRespawn) { + this.playerHitPolicy.applyLocalHitStun( + config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS, + ); + this.startRespawnSequence(this.myId, true); + } else { + this.playerHitPolicy.applyLocalHitStun(); + } this.onSendBombHitReport(payload.bombId); } /** ネットワーク被弾通知を適用する */ public handleNetworkPlayerHit(payload: PlayerHitPayload): void { this.playerHitPolicy.applyPlayerHitEvent(payload); + + const shouldStartRespawn = + this.incrementHitCount(payload.playerId) >= + config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT; + this.playerHitEffectOrchestrator.handleNetworkPlayerHit( payload.playerId, this.myId, ); + + if (shouldStartRespawn) { + this.startRespawnSequence(payload.playerId, false); + return; + } } /** 管理中リソースを破棄する */ public dispose(): void { this.bombHitOrchestrator.clear(); this.playerHitPolicy.dispose(); - if (this.respawnTimer) { - clearTimeout(this.respawnTimer); - this.respawnTimer = null; - } + this.respawnTimersByPlayerId.forEach((timerId) => { + clearTimeout(timerId); + }); + this.respawnTimersByPlayerId.clear(); } /** ローカル被弾回数を返す */ @@ -98,35 +121,60 @@ return this.localBombHitCount; } - private handleLocalBombHit(): void { + private handleLocalBombHit(): boolean { if (this.respawnState === "idle") { this.respawnState = "counting"; } - this.localBombHitCount += 1; + this.localBombHitCount = this.incrementHitCount(this.myId); this.onLocalBombHitCountChanged(this.localBombHitCount); if (this.localBombHitCount < config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - return; + return false; } this.respawnState = "pendingRespawn"; - if (this.respawnTimer) { - clearTimeout(this.respawnTimer); - this.respawnTimer = null; + return true; + } + + private startRespawnSequence(playerId: string, isLocalPlayer: boolean): void { + const existingTimer = this.respawnTimersByPlayerId.get(playerId); + if (existingTimer) { + clearTimeout(existingTimer); + this.respawnTimersByPlayerId.delete(playerId); } - this.respawnTimer = setTimeout(() => { - this.respawnTimer = null; + const target = this.players[playerId]; + if (target) { + target.setRespawnEffectVisible(true); + } - const me = this.players[this.myId]; - if (me) { - me.respawnToInitialPosition(); + 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_HIT_STUN_MS); + }, 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; } } diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts index f4d736d..7d98cc4 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerController.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -78,6 +78,11 @@ this.bombHitBlinkRenderer.play(durationMs); } + /** リスポーン演出の表示状態を切り替える */ + public setRespawnEffectVisible(visible: boolean): void { + this.view.setRespawnEffectVisible(visible); + } + /** 管理中の描画リソースを破棄する */ public destroy(): void { this.bombHitBlinkRenderer.destroy(); diff --git a/apps/client/src/scenes/game/entities/player/PlayerView.ts b/apps/client/src/scenes/game/entities/player/PlayerView.ts index ebd61dc..c167ff6 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerView.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerView.ts @@ -11,6 +11,9 @@ 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; @@ -19,6 +22,10 @@ this.displayObject = new Container(); + this.respawnEffectSprite = new Sprite(Texture.WHITE); + this.respawnEffectSprite.anchor.set(0.5, 0.5); + this.respawnEffectSprite.visible = false; + // 🌟 2. スプライト(画像)の生成(初期は1x1テクスチャ) this.sprite = new Sprite(Texture.WHITE); @@ -32,6 +39,11 @@ // ローカルプレイヤーだけ少し視認性を上げる(未使用引数対策を兼ねる) 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({ @@ -44,10 +56,47 @@ this.nameText.anchor.set(0.5, 0); this.nameText.y = PLAYER_RADIUS_PX * PLAYER_RENDER_SCALE + 8; - this.displayObject.addChild(this.sprite, this.nameText); + this.displayObject.addChild( + this.respawnEffectSprite, + this.sprite, + this.nameText, + ); // 非同期で画像テクスチャを読み込んで差し替える void this.applyTexture(imageFileName); + void this.applyRespawnEffectTexture(); + } + + /** リスポーン演出画像を読み込んで背面スプライトへ反映する */ + private async applyRespawnEffectTexture(): Promise { + try { + const imageUrl = `${import.meta.env.BASE_URL}bakuhatueffe.svg`; + const texture = await PlayerView.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; + } 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; } /** BASE_URL対応のURLで画像を読み込み、スプライトに反映する */ @@ -61,6 +110,10 @@ 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) { @@ -75,6 +128,12 @@ ); } } + + /** リスポーン演出の表示状態を更新する */ + public setRespawnEffectVisible(visible: boolean): void { + this.respawnEffectSprite.visible = visible; + } + /** グリッド座標を描画座標へ反映する */ public syncPosition(gridX: number, gridY: number): void { const { GRID_CELL_SIZE } = config.GAME_CONFIG; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 55ef970..2550486 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -22,6 +22,8 @@ PLAYER_RENDER_SCALE: 1, // プレイヤー見た目サイズ倍率(1=等倍) PLAYER_HIT_STUN_MS: 1000, // 被弾時に入力を停止する時間(ms) PLAYER_RESPAWN_HIT_COUNT: 5, // この回数被弾したら初期位置へリスポーンする + PLAYER_RESPAWN_STUN_MS: 2000, // リスポーン前にスタンする時間(ms) + PLAYER_RESPAWN_EFFECT_SCALE: 2.4, // bakuhatueffe演出の見た目サイズ倍率(1=等倍) // 爆弾設定(内部座標はグリッド単位、時間はms、契約値) BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定)