diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index 59a740f..20324f2 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -4,12 +4,18 @@ (sharedConfig.GAME_CONFIG as { BOMB_RENDER_SCALE?: number }) .BOMB_RENDER_SCALE ?? 1; +const sharedRespawnHitCount = + (sharedConfig.GAME_CONFIG as { PLAYER_RESPAWN_HIT_COUNT?: number }) + .PLAYER_RESPAWN_HIT_COUNT ?? 5; + const CLIENT_GAME_CONFIG = { TIMER_DISPLAY_UPDATE_MS: 250, JOIN_REQUEST_TIMEOUT_MS: 8000, FRAME_DELTA_MAX_MS: 50, + PLAYER_RESPAWN_HIT_COUNT: sharedRespawnHitCount, + PLAYER_LERP_SMOOTHNESS: 18, PLAYER_LERP_SNAP_THRESHOLD: 0.005, PLAYER_HIT_EFFECT: { diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 30463e6..c6c3c87 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -53,6 +53,7 @@ private lifecycleState: SceneLifecycleState; private uiStateSyncService: GameUiStateSyncService; private disposableRegistry: DisposableRegistry; + private localBombHitCount = 0; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -113,6 +114,10 @@ onSendBombHitReport: (bombId) => { gameActionSender.sendBombHitReport(bombId); }, + onLocalBombHitCountChanged: (count) => { + this.localBombHitCount = count; + this.uiStateSyncService.emitIfChanged(); + }, }); this.runtime = new GameSceneRuntime({ app: this.app, @@ -208,6 +213,7 @@ startCountdownSec: this.sessionFacade.getStartCountdownSec(), isInputEnabled: this.runtime.isInputEnabled(), teamPaintRates: this.runtime.getPaintRatesByTeam(), + localBombHitCount: this.localBombHitCount, }; } diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 80aaea7..0703325 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -19,6 +19,7 @@ startCountdownText, isInputEnabled, teamPaintRates, + localBombHitCount, handleInput, handlePlaceBomb, } = useGameSceneController(myId); @@ -29,6 +30,7 @@ startCountdownText={startCountdownText} isInputEnabled={isInputEnabled} teamPaintRates={teamPaintRates} + localBombHitCount={localBombHitCount} pixiContainerRef={pixiContainerRef} onJoystickInput={handleInput} onPlaceBomb={handlePlaceBomb} diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index adf497c..6f5ccdc 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -5,6 +5,7 @@ */ import { GameInputOverlay } from "./input/GameInputOverlay"; import { + GAME_VIEW_BOMB_HIT_DEBUG_STYLE, GAME_VIEW_FEVER_TEXT_STYLE, GAME_VIEW_PAINT_RATE_ITEM_STYLE, GAME_VIEW_PAINT_RATE_PANEL_STYLE, @@ -22,6 +23,7 @@ startCountdownText: string | null; isInputEnabled: boolean; teamPaintRates: number[]; + localBombHitCount: number; pixiContainerRef: React.RefObject; onJoystickInput: (x: number, y: number) => void; onPlaceBomb: () => boolean; @@ -89,12 +91,24 @@ ); }; +const buildHeartGauge = (localBombHitCount: number): string => { + const maxHearts = Math.max( + 1, + Math.floor(config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT), + ); + const clampedHitCount = Math.min(Math.max(localBombHitCount, 0), maxHearts); + const remainingHearts = maxHearts - clampedHitCount; + + return `${"❤️".repeat(remainingHearts)}${"🤍".repeat(clampedHitCount)}`; +}; + /** 画面描画と入力UIをまとめて描画する */ export const GameView = ({ timeLeft, startCountdownText, isInputEnabled, teamPaintRates, + localBombHitCount, pixiContainerRef, onJoystickInput, onPlaceBomb, @@ -102,12 +116,14 @@ const remainingSeconds = parseRemainingSeconds(timeLeft); const isFeverTime = remainingSeconds <= config.GAME_CONFIG.BOMB_FEVER_START_REMAINING_SEC; + const heartGauge = buildHeartGauge(localBombHitCount); return (
{/* タイマーUIの表示 */} +
HP: {heartGauge}
() => void; onSendBombHitReport: (bombId: string) => void; + onLocalBombHitCountChanged: (count: number) => void; }; /** 被弾関連ライフサイクルの制御を担当する */ export class CombatLifecycleFacade { + private readonly players: GamePlayers; private readonly myId: string; - private readonly onSendBombHitReport: ( - bombId: string, - ) => void; + 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 localBombHitCount = 0; + private isRespawnPending = false; + private respawnTimer: ReturnType | null = null; constructor({ players, myId, acquireInputLock, onSendBombHitReport, + onLocalBombHitCountChanged, }: CombatLifecycleFacadeOptions) { + this.players = players; this.myId = myId; this.onSendBombHitReport = onSendBombHitReport; + this.onLocalBombHitCountChanged = onLocalBombHitCountChanged; this.bombHitOrchestrator = new BombHitOrchestrator({ players, myId, @@ -57,7 +64,9 @@ public handleBombExploded(payload: BombExplodedPayload): void { const hitPlayerId = this.bombHitOrchestrator.evaluateHit(payload); if (!hitPlayerId) return; + if (this.isRespawnPending) return; + this.handleLocalBombHit(); this.playerHitPolicy.applyLocalHitStun(); this.playerHitEffectOrchestrator.handleLocalBombHit(this.myId); this.onSendBombHitReport(payload.bombId); @@ -66,12 +75,52 @@ /** ネットワーク被弾通知を適用する */ public handleNetworkPlayerHit(payload: PlayerHitPayload): void { this.playerHitPolicy.applyPlayerHitEvent(payload); - this.playerHitEffectOrchestrator.handleNetworkPlayerHit(payload.playerId, this.myId); + this.playerHitEffectOrchestrator.handleNetworkPlayerHit( + payload.playerId, + this.myId, + ); } /** 管理中リソースを破棄する */ public dispose(): void { this.bombHitOrchestrator.clear(); this.playerHitPolicy.dispose(); + if (this.respawnTimer) { + clearTimeout(this.respawnTimer); + this.respawnTimer = null; + } } -} \ No newline at end of file + + /** ローカル被弾回数を返す */ + public getLocalBombHitCount(): number { + return this.localBombHitCount; + } + + private handleLocalBombHit(): void { + this.localBombHitCount += 1; + this.onLocalBombHitCountChanged(this.localBombHitCount); + + if (this.localBombHitCount < config.GAME_CONFIG.PLAYER_RESPAWN_HIT_COUNT) { + return; + } + + this.isRespawnPending = true; + 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.isRespawnPending = false; + this.onLocalBombHitCountChanged(this.localBombHitCount); + }, config.GAME_CONFIG.PLAYER_HIT_STUN_MS); + } +} diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts index f324b59..0152bb0 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -16,6 +16,7 @@ startCountdownSec: number; isInputEnabled: boolean; teamPaintRates: number[]; + localBombHitCount: number; }; const isSamePaintRates = (a: number[], b: number[]): boolean => { @@ -72,6 +73,7 @@ this.lastState.remainingTimeSec === snapshot.remainingTimeSec && this.lastState.startCountdownSec === snapshot.startCountdownSec && this.lastState.isInputEnabled === snapshot.isInputEnabled && + this.lastState.localBombHitCount === snapshot.localBombHitCount && isSamePaintRates(this.lastState.teamPaintRates, snapshot.teamPaintRates) ) { return; diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts index 470f039..f4d736d 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerController.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -66,6 +66,13 @@ return this.model.getSnapshot(); } + /** 初期位置へリスポーンし描画位置を同期する */ + public respawnToInitialPosition(): void { + this.model.resetToInitialPosition(); + const position = this.model.getPosition(); + this.view.syncPosition(position.x, position.y); + } + /** 爆弾被弾時の点滅演出を再生する */ public playBombHitBlink(durationMs: number): void { this.bombHitBlinkRenderer.play(durationMs); diff --git a/apps/client/src/scenes/game/entities/player/PlayerModel.ts b/apps/client/src/scenes/game/entities/player/PlayerModel.ts index 1825f8d..2e6bcfd 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerModel.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerModel.ts @@ -11,6 +11,8 @@ public readonly id: string; public readonly name: string; public readonly teamId: number; + private readonly initialGridX: number; + private readonly initialGridY: number; private gridX: number; private gridY: number; @@ -22,6 +24,8 @@ this.id = data.id; this.name = data.name; this.teamId = data.teamId; + this.initialGridX = data.x; + this.initialGridY = data.y; this.gridX = data.x; this.gridY = data.y; this.targetGridX = data.x; @@ -64,13 +68,24 @@ } /** リモート更新の目標座標を設定する */ - public setRemoteTarget(update: Partial): void { + public setRemoteTarget( + update: Partial, + ): void { if (update.x !== undefined && this.isFiniteNumber(update.x)) this.targetGridX = update.x; if (update.y !== undefined && this.isFiniteNumber(update.y)) this.targetGridY = update.y; } + /** 初期位置へ座標を戻す */ + public resetToInitialPosition(): void { + this.gridX = this.initialGridX; + this.gridY = this.initialGridY; + this.targetGridX = this.initialGridX; + this.targetGridY = this.initialGridY; + this.clampToBounds(); + } + /** 目標座標に向けて補間更新する */ public updateRemoteLerp(deltaTime: number): void { if (!this.isFiniteNumber(deltaTime)) { diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index 07ac595..3b1b718 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -28,6 +28,7 @@ const [teamPaintRates, setTeamPaintRates] = useState( DEFAULT_TEAM_PAINT_RATES, ); + const [localBombHitCount, setLocalBombHitCount] = useState(0); useEffect(() => { if (!pixiContainerRef.current || !myId) return; @@ -51,6 +52,7 @@ ); setTeamPaintRates(state.teamPaintRates); + setLocalBombHitCount(state.localBombHitCount); }); return () => { @@ -60,6 +62,7 @@ setStartCountdownText(null); setIsInputEnabled(false); setTeamPaintRates(DEFAULT_TEAM_PAINT_RATES); + setLocalBombHitCount(0); }; }, [myId]); @@ -77,6 +80,7 @@ startCountdownText, isInputEnabled, teamPaintRates, + localBombHitCount, handleInput, handlePlaceBomb, }; diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts index 77dd531..15ddc98 100644 --- a/apps/client/src/scenes/game/styles/GameView.styles.ts +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -65,6 +65,22 @@ fontSize: "14px", }; +/** 左上の被弾回数デバッグ表示スタイル */ +export const GAME_VIEW_BOMB_HIT_DEBUG_STYLE: CSSProperties = { + position: "absolute", + top: "20px", + left: "16px", + zIndex: 12, + color: "white", + fontSize: "14px", + fontWeight: 700, + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontFamily: "monospace", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", +}; + /** Pixi描画レイヤーの配置スタイル */ export const GAME_VIEW_PIXI_LAYER_STYLE: CSSProperties = { position: "absolute", diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index ee1f701..55ef970 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -21,6 +21,7 @@ PLAYER_SPEED: 3, // 1秒当たりの移動量(グリッド単位) PLAYER_RENDER_SCALE: 1, // プレイヤー見た目サイズ倍率(1=等倍) PLAYER_HIT_STUN_MS: 1000, // 被弾時に入力を停止する時間(ms) + PLAYER_RESPAWN_HIT_COUNT: 5, // この回数被弾したら初期位置へリスポーンする // 爆弾設定(内部座標はグリッド単位、時間はms、契約値) BOMB_RADIUS_GRID: 1.5, // 爆風半径(グリッド単位、円形当たり判定)