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