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, // 爆風半径(グリッド単位、円形当たり判定)