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..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,20 +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 respawnTimer: ReturnType | null = null; constructor({ players, @@ -42,7 +39,6 @@ onSendBombHitReport, onLocalBombHitCountChanged, }: CombatLifecycleFacadeOptions) { - this.players = players; this.myId = myId; this.onSendBombHitReport = onSendBombHitReport; this.onLocalBombHitCountChanged = onLocalBombHitCountChanged; @@ -60,37 +56,59 @@ blinkDurationMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_DURATION_MS, dedupWindowMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.DEDUP_WINDOW_MS, }); + 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; - 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.respawnManager.startSequence(this.myId); + } else { + this.playerHitPolicy.applyLocalHitStun(); + } this.onSendBombHitReport(payload.bombId); } /** ネットワーク被弾通知を適用する */ 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); + } } /** 管理中リソースを破棄する */ public dispose(): void { this.bombHitOrchestrator.clear(); this.playerHitPolicy.dispose(); - if (this.respawnTimer) { - clearTimeout(this.respawnTimer); - this.respawnTimer = null; - } + this.respawnManager.dispose(); } /** ローカル被弾回数を返す */ @@ -98,35 +116,11 @@ return this.localBombHitCount; } - private handleLocalBombHit(): void { - if (this.respawnState === "idle") { - this.respawnState = "counting"; - } - - this.localBombHitCount += 1; + private handleLocalBombHit(): boolean { + this.localBombHitCount = this.respawnManager.incrementHitCount(this.myId); this.onLocalBombHitCountChanged(this.localBombHitCount); - - if (this.localBombHitCount < config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { - return; - } - - this.respawnState = "pendingRespawn"; - if (this.respawnTimer) { - clearTimeout(this.respawnTimer); - this.respawnTimer = null; - } - - this.respawnTimer = setTimeout(() => { - this.respawnTimer = null; - - const me = this.players[this.myId]; - if (me) { - me.respawnToInitialPosition(); - } - - this.localBombHitCount = 0; - this.respawnState = "idle"; - this.onLocalBombHitCountChanged(this.localBombHitCount); - }, config.GAME_CONFIG.PLAYER_HIT_STUN_MS); + 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/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..8e6f8a5 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerView.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerView.ts @@ -6,11 +6,13 @@ 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 readonly respawnEffectSprite: Sprite; private readonly sprite: Sprite; private readonly nameText: Text; @@ -19,6 +21,11 @@ this.displayObject = new Container(); + 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); @@ -44,10 +51,34 @@ 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 loadRespawnEffectTexture(imageUrl); + this.respawnEffectSprite.texture = texture; + this.applyRespawnEffectSize(); + } catch (error) { + console.error("[PlayerView] bakuhatueffe.svg の読み込みに失敗", error); + } + } + + /** リスポーン演出スプライトに設定サイズを適用する */ + private applyRespawnEffectSize(): void { + const size = config.GAME_CONFIG.PLAYER_RESPAWN_EFFECT_SIZE_PX; + this.respawnEffectSprite.width = size; + this.respawnEffectSprite.height = size; } /** BASE_URL対応のURLで画像を読み込み、スプライトに反映する */ @@ -75,6 +106,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/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; +} diff --git a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts index cf2fb6b..470c413 100644 --- a/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts +++ b/apps/server/src/domains/game/application/services/bot/orchestrators/BotTurnOrchestrator.ts @@ -22,6 +22,7 @@ private readonly hitStunPolicy = new BotHitStunPolicy({ hitStunMs: config.GAME_CONFIG.PLAYER_HIT_STUN_MS, }); + private readonly respawnAtMsByBotId = new Map(); public decide( botPlayerId: BotPlayerId, @@ -42,6 +43,22 @@ stunUntilMs: Number.NEGATIVE_INFINITY, }); + // リスポーン時刻に達していたら初期位置へ座標をリセットする + const respawnAtMs = this.respawnAtMsByBotId.get(botPlayerId); + if (respawnAtMs !== undefined && nowMs >= respawnAtMs) { + this.respawnAtMsByBotId.delete(botPlayerId); + this.stateStore.update(botPlayerId, (state) => ({ + ...state, + targetCol: clamp(Math.floor(player.initialX), 0, GRID_COLS - 1), + targetRow: clamp(Math.floor(player.initialY), 0, GRID_ROWS - 1), + })); + return { + nextX: player.initialX, + nextY: player.initialY, + placeBombPayload: null, + }; + } + if (this.hitStunPolicy.isStunned(nowMs, currentState.stunUntilMs)) { this.stateStore.set(botPlayerId, { ...currentState, @@ -109,7 +126,19 @@ }); } + /** 指定Botへリスポーン用硬直と位置リセットタイマーを適用する */ + public applyRespawnStun(botPlayerId: BotPlayerId, nowMs: number): void { + const respawnStunMs = config.GAME_CONFIG.PLAYER_RESPAWN_STUN_MS; + const respawnAtMs = nowMs + respawnStunMs; + this.respawnAtMsByBotId.set(botPlayerId, respawnAtMs); + this.stateStore.update(botPlayerId, (state) => ({ + ...state, + stunUntilMs: Math.max(state.stunUntilMs, respawnAtMs), + })); + } + public clear(): void { this.stateStore.clear(); + this.respawnAtMsByBotId.clear(); } } diff --git a/apps/server/src/domains/game/entities/player/Player.ts b/apps/server/src/domains/game/entities/player/Player.ts index 79a4a3e..2208683 100644 --- a/apps/server/src/domains/game/entities/player/Player.ts +++ b/apps/server/src/domains/game/entities/player/Player.ts @@ -11,6 +11,11 @@ public y: number = 0; public teamId: number; + /** スポーン時の初期X座標(リスポーン位置として参照する) */ + public initialX: number = 0; + /** スポーン時の初期Y座標(リスポーン位置として参照する) */ + public initialY: number = 0; + /** セルの色を塗り替えた回数 */ public paintCount: number = 0; /** 爆弾を敵に当てた回数 */ diff --git a/apps/server/src/domains/game/entities/player/playerSpawn.ts b/apps/server/src/domains/game/entities/player/playerSpawn.ts index 6315689..62340bd 100644 --- a/apps/server/src/domains/game/entities/player/playerSpawn.ts +++ b/apps/server/src/domains/game/entities/player/playerSpawn.ts @@ -44,5 +44,9 @@ player.x = Math.max(1, Math.min(GRID_COLS - 1, baseX + scatterX)); player.y = Math.max(1, Math.min(GRID_ROWS - 1, baseY + scatterY)); + // リスポーン時に戻る座標として初期位置を保持する + player.initialX = player.x; + player.initialY = player.y; + return player; }; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index cb2b7d9..731bdca 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -61,8 +61,8 @@ private lastSentPlayers: Map = new Map(); private disconnectedBotControlledPlayerIds: Set = new Set(); - private botTurnOrchestrator: BotTurnOrchestrator = - new BotTurnOrchestrator(); + private botTurnOrchestrator: BotTurnOrchestrator = new BotTurnOrchestrator(); + private readonly botReceivedHitCountById = new Map(); private readonly roomId: string; private readonly tickRate: number; @@ -218,10 +218,23 @@ }); if (result.isHit) { - this.botTurnOrchestrator.applyHitStun( - player.id as BotPlayerId, - nowMs, - ); + // 被弾カウントを更新し,閾値到達でリスポーンスタン,それ以外は通常スタンを適用する + 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, + ); + } // 爆弾所有者の bombHitCount を加算する const owner = this.players.get(bomb.ownerPlayerId); 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, // 爆風半径(グリッド単位、円形当たり判定)