diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 8f627c9..861ead2 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -8,6 +8,7 @@ import { SceneLifecycleState } from "./application/lifecycle/SceneLifecycleState"; import { GameSessionFacade } from "./application/lifecycle/GameSessionFacade"; import { CombatLifecycleFacade } from "./application/combat/CombatLifecycleFacade"; +import { DisposableRegistry } from "./application/lifecycle/DisposableRegistry"; import { type GameSceneFactoryOptions, } from "./application/orchestrators/GameSceneOrchestrator"; @@ -51,6 +52,7 @@ private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; private uiStateSyncService: GameUiStateSyncService; + private disposableRegistry: DisposableRegistry; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -140,6 +142,30 @@ this.uiStateSyncService = new GameUiStateSyncService({ getSnapshot: () => this.getUiStateSnapshot(), }); + this.disposableRegistry = new DisposableRegistry(); + this.disposableRegistry.add(() => { + this.uiStateSyncService.clear(); + }); + this.disposableRegistry.add(() => { + this.players = {}; + }); + this.disposableRegistry.add(() => { + this.sessionFacade.reset(); + }); + this.disposableRegistry.add(() => { + this.combatFacade.dispose(); + }); + this.disposableRegistry.add(() => { + this.runtime.destroy(); + }); + this.disposableRegistry.add(() => { + if (this.lifecycleState.shouldDestroyApp()) { + this.app.destroy(true, { children: true }); + } + }); + this.disposableRegistry.add(() => { + this.uiStateSyncService.stopTicker(); + }); } /** @@ -205,14 +231,6 @@ */ public destroy() { this.lifecycleState.markDestroyed(); - this.uiStateSyncService.stopTicker(); - if (this.lifecycleState.shouldDestroyApp()) { - this.app.destroy(true, { children: true }); - } - this.runtime.destroy(); - this.combatFacade.dispose(); - this.sessionFacade.reset(); - this.players = {}; - this.uiStateSyncService.clear(); + this.disposableRegistry.disposeAll(); } } diff --git a/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts b/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts new file mode 100644 index 0000000..bf7601b --- /dev/null +++ b/apps/client/src/scenes/game/application/lifecycle/DisposableRegistry.ts @@ -0,0 +1,36 @@ +/** + * DisposableRegistry + * 破棄処理を登録して一括実行する + * 登録順の逆順で実行して依存順の安全なクリーンアップを行う + */ + +/** 後始末として実行する破棄処理の関数型 */ +export type Disposer = () => void; + +/** 破棄処理の登録と一括実行を管理するレジストリ */ +export class DisposableRegistry { + private disposers: Disposer[] = []; + + /** 破棄処理を登録し登録解除関数を返す */ + public add(disposer: Disposer): () => void { + this.disposers.push(disposer); + + return () => { + this.disposers = this.disposers.filter((target) => target !== disposer); + }; + } + + /** 登録済み破棄処理を逆順で実行して登録をクリアする */ + public disposeAll(): void { + for (let index = this.disposers.length - 1; index >= 0; index -= 1) { + this.disposers[index](); + } + + this.disposers = []; + } + + /** 破棄処理を実行せず登録のみをクリアする */ + public clear(): void { + this.disposers = []; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 977e0d5..224ea18 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -17,6 +17,7 @@ import type { MoveSender } from "../network/PlayerMoveSender"; import type { GameActionSender } from "../network/GameActionSender"; import type { GameSessionFacade } from "../lifecycle/GameSessionFacade"; +import { DisposableRegistry } from "../lifecycle/DisposableRegistry"; export type GameSceneRuntimeOptions = { app: Application; @@ -43,6 +44,7 @@ private readonly getElapsedMs: () => number; private readonly eventPorts: GameSceneEventPorts; private readonly sceneFactories?: GameSceneFactoryOptions; + private readonly disposableRegistry = new DisposableRegistry(); private readonly appearanceResolver = new AppearanceResolver(); private bombManager: BombManager | null = null; @@ -72,6 +74,13 @@ this.getElapsedMs = getElapsedMs; this.eventPorts = eventPorts; this.sceneFactories = sceneFactories; + + this.disposableRegistry.add(() => { + this.clearJoystickInput(); + }); + this.disposableRegistry.add(() => { + this.gameLoop = null; + }); } /** シーン実行に必要なサブシステムを初期化する */ @@ -93,6 +102,15 @@ this.networkSync = initializedScene.networkSync; this.bombManager = initializedScene.bombManager; this.gameLoop = initializedScene.gameLoop; + + this.disposableRegistry.add(() => { + this.networkSync?.unbind(); + this.networkSync = null; + }); + this.disposableRegistry.add(() => { + this.bombManager?.destroy(); + this.bombManager = null; + }); } public isInputEnabled(): boolean { @@ -131,11 +149,6 @@ /** 実行系サブシステムを破棄する */ public destroy(): void { - this.bombManager?.destroy(); - this.bombManager = null; - this.networkSync?.unbind(); - this.networkSync = null; - this.gameLoop = null; - this.clearJoystickInput(); + this.disposableRegistry.disposeAll(); } } diff --git a/apps/client/src/scenes/game/application/time/TimeProvider.ts b/apps/client/src/scenes/game/application/time/TimeProvider.ts new file mode 100644 index 0000000..f02a1e9 --- /dev/null +++ b/apps/client/src/scenes/game/application/time/TimeProvider.ts @@ -0,0 +1,15 @@ +/** + * TimeProvider + * 時刻取得の依存を抽象化する + * テスト時に任意時刻を注入できるようにする + */ + +/** 現在時刻ミリ秒を返す時刻取得インターフェース */ +export type TimeProvider = { + now: () => number; +}; + +/** 実行環境の現在時刻を返す既定の時刻取得実装 */ +export const SYSTEM_TIME_PROVIDER: TimeProvider = { + now: () => Date.now(), +}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts index 5d3034b..3f23c7c 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -3,6 +3,11 @@ * ゲーム画面向けUI状態の購読と差分通知を管理する * 秒境界に揃えた定期通知と購読解除を提供する */ +import { + SYSTEM_TIME_PROVIDER, + type TimeProvider, +} from "@client/scenes/game/application/time/TimeProvider"; + const UI_STATE_SECOND_MS = 1000; /** ゲーム画面UIへ通知する状態スナップショット */ @@ -14,18 +19,21 @@ type GameUiStateSyncServiceOptions = { getSnapshot: () => GameUiState; + timeProvider?: TimeProvider; }; /** UI状態の購読と定期通知を管理するサービス */ export class GameUiStateSyncService { private readonly getSnapshot: () => GameUiState; + private readonly timeProvider: TimeProvider; private readonly listeners = new Set<(state: GameUiState) => void>(); private lastState: GameUiState | null = null; private alignTimeoutId: number | null = null; private timerId: number | null = null; - constructor({ getSnapshot }: GameUiStateSyncServiceOptions) { + constructor({ getSnapshot, timeProvider }: GameUiStateSyncServiceOptions) { this.getSnapshot = getSnapshot; + this.timeProvider = timeProvider ?? SYSTEM_TIME_PROVIDER; } public subscribe(listener: (state: GameUiState) => void): () => void { @@ -64,7 +72,7 @@ return; } - const nowMs = Date.now(); + const nowMs = this.timeProvider.now(); const delayToNextSecond = UI_STATE_SECOND_MS - (nowMs % UI_STATE_SECOND_MS); this.alignTimeoutId = window.setTimeout(() => { diff --git a/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts index 452c1de..2a5243b 100644 --- a/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts +++ b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts @@ -4,6 +4,10 @@ * トリガー時刻を保持し一定間隔で再計算する */ import { useCallback, useEffect, useMemo, useState } from "react"; +import { + SYSTEM_TIME_PROVIDER, + type TimeProvider, +} from "@client/scenes/game/application/time/TimeProvider"; const COOLDOWN_TICK_MS = 50; @@ -20,10 +24,20 @@ remainingSecText: null, }; +/** useCooldownClock の依存注入オプション */ +export type UseCooldownClockOptions = { + timeProvider?: TimeProvider; +}; + /** クールダウン状態とトリガー操作を提供するフック */ -export const useCooldownClock = (cooldownMs: number) => { +export const useCooldownClock = ( + cooldownMs: number, + options?: UseCooldownClockOptions, +) => { + const timeProvider = options?.timeProvider ?? SYSTEM_TIME_PROVIDER; + const getNow = useCallback(() => timeProvider.now(), [timeProvider]); const [lastTriggeredAt, setLastTriggeredAt] = useState(null); - const [nowMs, setNowMs] = useState(() => Date.now()); + const [nowMs, setNowMs] = useState(() => getNow()); useEffect(() => { if (lastTriggeredAt === null || cooldownMs <= 0) { @@ -31,13 +45,13 @@ } const timerId = window.setInterval(() => { - setNowMs(Date.now()); + setNowMs(getNow()); }, COOLDOWN_TICK_MS); return () => { window.clearInterval(timerId); }; - }, [cooldownMs, lastTriggeredAt]); + }, [cooldownMs, getNow, lastTriggeredAt]); const cooldownState = useMemo(() => { if (cooldownMs <= 0 || lastTriggeredAt === null) { @@ -58,10 +72,10 @@ }, [cooldownMs, lastTriggeredAt, nowMs]); const markTriggered = useCallback(() => { - const now = Date.now(); + const now = getNow(); setLastTriggeredAt(now); setNowMs(now); - }, []); + }, [getNow]); return { cooldownState,