diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index a250d36..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"; @@ -21,6 +22,10 @@ type MoveSender, } from "./application/network/PlayerMoveSender"; import type { GamePlayers } from "./application/game.types"; +import { + GameUiStateSyncService, + type GameUiState, +} from "./application/ui/GameUiStateSyncService"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -31,12 +36,8 @@ sceneFactories?: GameSceneFactoryOptions; }; -/** GameScene の UI 表示に必要な状態 */ -export type GameUiState = { - remainingTimeSec: number; - startCountdownSec: number; - isInputEnabled: boolean; -}; +/** GameScene の UI 表示状態型を外部参照向けに再公開する */ +export type { GameUiState } from "./application/ui/GameUiStateSyncService"; /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ export class GameManager { @@ -50,8 +51,8 @@ private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; - private uiStateListeners = new Set<(state: GameUiState) => void>(); - private lastUiState: GameUiState | null = null; + private uiStateSyncService: GameUiStateSyncService; + private disposableRegistry: DisposableRegistry; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -73,8 +74,12 @@ public lockInput(): () => void { this.runtime.clearJoystickInput(); const release = this.sessionFacade.lockInput(); - this.emitUiStateIfChanged(true); - return release; + this.uiStateSyncService.emitIfChanged(); + + return () => { + release(); + this.uiStateSyncService.emitIfChanged(); + }; } constructor( @@ -95,6 +100,7 @@ this.gameEventFacade = new GameEventFacade({ onGameStart: (startTime) => { this.sessionFacade.setGameStart(startTime); + this.uiStateSyncService.emitIfChanged(); }, getBombManager: () => this.runtime.getBombManager(), }); @@ -115,22 +121,51 @@ gameActionSender, moveSender, getElapsedMs: () => this.sessionFacade.getElapsedMs(), - onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade), - onGameEnd: this.lockInput.bind(this), - onBombPlacedFromOthers: (payload) => { - this.gameEventFacade.handleBombPlacedFromOthers(payload); - }, - onBombPlacedAckFromNetwork: (payload) => { - this.gameEventFacade.handleBombPlacedAck(payload); - }, - onPlayerDeadFromNetwork: (payload) => { - this.combatFacade.handleNetworkPlayerDead(payload); - }, - onBombExploded: (payload) => { - this.combatFacade.handleBombExploded(payload); + eventPorts: { + onGameStart: this.gameEventFacade.handleGameStart.bind(this.gameEventFacade), + onGameEnd: this.lockInput.bind(this), + onBombPlacedFromOthers: (payload) => { + this.gameEventFacade.handleBombPlacedFromOthers(payload); + }, + onBombPlacedAckFromNetwork: (payload) => { + this.gameEventFacade.handleBombPlacedAck(payload); + }, + onPlayerDeadFromNetwork: (payload) => { + this.combatFacade.handleNetworkPlayerDead(payload); + }, + onBombExploded: (payload) => { + this.combatFacade.handleBombExploded(payload); + }, }, sceneFactories, }); + 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(); + }); } /** @@ -160,7 +195,8 @@ // メインループの登録 this.app.ticker.add(this.tick); this.lifecycleState.markInitialized(); - this.emitUiStateIfChanged(true); + this.uiStateSyncService.startTicker(); + this.uiStateSyncService.emitIfChanged(true); } /** @@ -175,17 +211,11 @@ */ private tick = (ticker: Ticker) => { this.runtime.tick(ticker); - this.emitUiStateIfChanged(); }; /** UI状態購読を登録し,解除関数を返す */ public subscribeUiState(listener: (state: GameUiState) => void): () => void { - this.uiStateListeners.add(listener); - listener(this.getUiStateSnapshot()); - - return () => { - this.uiStateListeners.delete(listener); - }; + return this.uiStateSyncService.subscribe(listener); } private getUiStateSnapshot(): GameUiState { @@ -196,41 +226,11 @@ }; } - private emitUiStateIfChanged(force = false): void { - if (this.uiStateListeners.size === 0 && !force) { - return; - } - - const snapshot = this.getUiStateSnapshot(); - if ( - !force - && this.lastUiState - && this.lastUiState.remainingTimeSec === snapshot.remainingTimeSec - && this.lastUiState.startCountdownSec === snapshot.startCountdownSec - && this.lastUiState.isInputEnabled === snapshot.isInputEnabled - ) { - return; - } - - this.lastUiState = snapshot; - this.uiStateListeners.forEach((listener) => { - listener(snapshot); - }); - } - /** * クリーンアップ処理(コンポーネントアンマウント時) */ public destroy() { this.lifecycleState.markDestroyed(); - if (this.lifecycleState.shouldDestroyApp()) { - this.app.destroy(true, { children: true }); - } - this.runtime.destroy(); - this.combatFacade.dispose(); - this.sessionFacade.reset(); - this.players = {}; - this.uiStateListeners.clear(); - this.lastUiState = null; + this.disposableRegistry.disposeAll(); } } diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx index b84cb17..9120496 100644 --- a/apps/client/src/scenes/game/GameView.tsx +++ b/apps/client/src/scenes/game/GameView.tsx @@ -4,6 +4,12 @@ * タイマー表示,PixiJSの描画領域,入力UIの配置のみを担当する */ import { GameInputOverlay } from "./input/GameInputOverlay"; +import { + PIXI_LAYER_STYLE, + ROOT_STYLE, + START_COUNTDOWN_STYLE, + TIMER_STYLE, +} from "./styles/GameView.styles"; /** 表示と入力に必要なプロパティ */ type Props = { @@ -15,54 +21,6 @@ onPlaceBomb: () => boolean; }; -const ROOT_STYLE: React.CSSProperties = { - width: "100vw", - height: "100vh", - overflow: "hidden", - position: "relative", - backgroundColor: "#000", - userSelect: "none", - WebkitUserSelect: "none", -}; - -const TIMER_STYLE: React.CSSProperties = { - position: "absolute", - top: "20px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 10, - color: "white", - fontSize: "32px", - fontWeight: "bold", - textShadow: "2px 2px 4px rgba(0,0,0,0.5)", - fontFamily: "monospace", - userSelect: "none", - WebkitUserSelect: "none", -}; - -const PIXI_LAYER_STYLE: React.CSSProperties = { - position: "absolute", - top: 0, - left: 0, - zIndex: 1, -}; - -const START_COUNTDOWN_STYLE: React.CSSProperties = { - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - zIndex: 30, - color: "white", - fontSize: "clamp(3rem, 14vw, 8rem)", - fontWeight: 900, - textShadow: "0 0 16px rgba(0,0,0,0.85)", - fontFamily: "monospace", - userSelect: "none", - WebkitUserSelect: "none", - pointerEvents: "none", -}; - const TimerOverlay = ({ timeLeft }: { timeLeft: string }) => (
{timeLeft}
); diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 1ac43ba..74d807f 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -12,6 +12,7 @@ import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; +import type { LoopFrameContext, LoopStep } from "./loopSteps/LoopStep"; import { resolveFrameDelta } from "./loopSteps/frameDelta"; import type { MoveSender } from "./network/PlayerMoveSender"; @@ -35,6 +36,7 @@ private simulationStep: SimulationStep; private bombStep: BombStep; private cameraStep: CameraStep; + private steps: LoopStep[]; constructor({ app, worldContainer, players, myId, getJoystickInput, bombManager, moveSender }: GameLoopOptions) { this.app = app; @@ -47,6 +49,12 @@ }); this.bombStep = new BombStep({ bombManager }); this.cameraStep = new CameraStep(); + this.steps = [ + this.inputStep, + this.simulationStep, + this.bombStep, + this.cameraStep, + ]; } public tick = (ticker: Ticker) => { @@ -57,21 +65,17 @@ ticker, config.GAME_CONFIG.FRAME_DELTA_MAX_MS, ); - const { isMoving } = this.inputStep.run({ me, deltaSeconds }); - - this.simulationStep.run({ - me, - players: this.players, - deltaSeconds, - isMoving, - }); - - this.bombStep.run(); - - this.cameraStep.run({ + const frameContext: LoopFrameContext = { app: this.app, worldContainer: this.worldContainer, + players: this.players, me, + deltaSeconds, + isMoving: false, + }; + + this.steps.forEach((step) => { + step.run(frameContext); }); }; } 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/loopSteps/BombStep.ts b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts index f7d9d71..3166a0c 100644 --- a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts @@ -4,6 +4,7 @@ * 爆弾エンティティの時間更新と状態遷移を実行する */ import { BombManager } from "@client/scenes/game/entities/bomb/BombManager"; +import type { LoopFrameContext, LoopStep } from "./LoopStep"; /** BombStep の初期化入力 */ type BombStepOptions = { @@ -11,7 +12,7 @@ }; /** 爆弾更新処理を担うステップ */ -export class BombStep { +export class BombStep implements LoopStep { private bombManager: BombManager; constructor({ bombManager }: BombStepOptions) { @@ -19,7 +20,7 @@ } /** 爆弾更新を実行する,時間管理は GameTimer 由来の経過時刻を利用する */ - public run(): void { + public run(_context: LoopFrameContext): void { this.bombManager.tick(); } } diff --git a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts index e229842..a72d16f 100644 --- a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts @@ -5,6 +5,7 @@ */ import { Application, Container } from "pixi.js"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; +import type { LoopFrameContext, LoopStep } from "./LoopStep"; type CameraStepParams = { app: Application; @@ -13,8 +14,16 @@ }; /** カメラ追従更新を担うステップ */ -export class CameraStep { - public run({ app, worldContainer, me }: CameraStepParams) { +export class CameraStep implements LoopStep { + /** ローカルプレイヤー位置へカメラを追従させる */ + public run(context: LoopFrameContext): void { + const params: CameraStepParams = { + app: context.app, + worldContainer: context.worldContainer, + me: context.me, + }; + + const { app, worldContainer, me } = params; const meDisplay = me.getDisplayObject(); worldContainer.position.set(-(meDisplay.x - app.screen.width / 2), -(meDisplay.y - app.screen.height / 2)); } diff --git a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts index 515e1c3..5b2ace4 100644 --- a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts @@ -4,6 +4,7 @@ * ジョイスティック入力をローカルプレイヤーへ適用する */ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; +import type { LoopFrameContext, LoopStep } from "./LoopStep"; type InputStepOptions = { getJoystickInput: () => { x: number; y: number }; @@ -14,19 +15,26 @@ deltaSeconds: number; }; -type InputStepResult = { - isMoving: boolean; -}; - /** 入力段の更新処理を担うステップ */ -export class InputStep { +export class InputStep implements LoopStep { private getJoystickInput: () => { x: number; y: number }; constructor({ getJoystickInput }: InputStepOptions) { this.getJoystickInput = getJoystickInput; } - public run({ me, deltaSeconds }: InputStepParams): InputStepResult { + /** 入力文脈を適用して移動状態を更新する */ + public run(context: LoopFrameContext): void { + const params: InputStepParams = { + me: context.me, + deltaSeconds: context.deltaSeconds, + }; + + const isMoving = this.applyInput(params); + context.isMoving = isMoving; + } + + private applyInput({ me, deltaSeconds }: InputStepParams): boolean { const { x: axisX, y: axisY } = this.getJoystickInput(); const isMoving = axisX !== 0 || axisY !== 0; @@ -34,6 +42,6 @@ me.applyLocalInput({ axisX, axisY, deltaTime: deltaSeconds }); } - return { isMoving }; + return isMoving; } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts new file mode 100644 index 0000000..f94e9c4 --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts @@ -0,0 +1,23 @@ +/** + * LoopStep + * ゲームループの共通ステップ契約を定義する + * 各ステップ間で受け渡すフレーム文脈を提供する + */ +import type { Application, Container } from "pixi.js"; +import type { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; +import type { GamePlayers } from "@client/scenes/game/application/game.types"; + +/** 1フレーム分の更新文脈を表す型 */ +export type LoopFrameContext = { + app: Application; + worldContainer: Container; + players: GamePlayers; + me: LocalPlayerController; + deltaSeconds: number; + isMoving: boolean; +}; + +/** ゲームループ内で実行されるステップ共通インターフェース */ +export type LoopStep = { + run: (context: LoopFrameContext) => void; +}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index 92b13b3..2f5a34c 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -7,6 +7,7 @@ import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController"; import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; import type { GamePlayers } from "../game.types"; +import type { LoopFrameContext, LoopStep } from "./LoopStep"; /** SimulationStep の初期化入力 */ type SimulationStepOptions = { @@ -22,7 +23,7 @@ }; /** シミュレーション段の更新処理を担うステップ */ -export class SimulationStep { +export class SimulationStep implements LoopStep { private readonly moveSender: MoveSender; private readonly nowMsProvider: () => number; private lastPositionSentTime = 0; @@ -33,9 +34,17 @@ this.nowMsProvider = nowMsProvider; } - public run({ me, players, deltaSeconds, isMoving }: SimulationStepParams) { - this.runLocalSimulation({ me, isMoving }); - this.runRemoteSimulation({ players, deltaSeconds }); + /** ローカル更新とリモート補間更新を実行する */ + public run(context: LoopFrameContext): void { + const params: SimulationStepParams = { + me: context.me, + players: context.players, + deltaSeconds: context.deltaSeconds, + isMoving: context.isMoving, + }; + + this.runLocalSimulation({ me: params.me, isMoving: params.isMoving }); + this.runRemoteSimulation({ players: params.players, deltaSeconds: params.deltaSeconds }); } private runLocalSimulation({ me, isMoving }: Pick) { diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index 8e43682..c670f80 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -41,6 +41,16 @@ onBombExploded: (payload: BombExplodedPayload) => void; }; +/** シーン層で扱うイベント通知ポート群 */ +export type GameSceneEventPorts = { + onGameStart: (startTime: number) => void; + onGameEnd: () => void; + onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; + onBombExploded: (payload: BombExplodedPayload) => void; +}; + /** GameLoop 生成入力型 */ export type CreateGameLoopOptions = { app: Application; @@ -69,12 +79,7 @@ getElapsedMs: () => number; getJoystickInput: () => { x: number; y: number }; moveSender: MoveSender; - onGameStart: (startTime: number) => void; - onGameEnd: () => void; - onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - onBombExploded: (payload: BombExplodedPayload) => void; + eventPorts: GameSceneEventPorts; factories?: GameSceneFactoryOptions; }; @@ -96,12 +101,7 @@ private readonly getElapsedMs: () => number; private readonly getJoystickInput: () => { x: number; y: number }; private readonly moveSender: MoveSender; - private readonly onGameStart: (startTime: number) => void; - private readonly onGameEnd: () => void; - private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private readonly onBombExploded: (payload: BombExplodedPayload) => void; + private readonly eventPorts: GameSceneEventPorts; private readonly createNetworkSync: (options: CreateNetworkSyncOptions) => GameNetworkSync; private readonly createBombManager: (options: CreateBombManagerOptions) => BombManager; private readonly createGameLoop: (options: CreateGameLoopOptions) => GameLoop; @@ -115,12 +115,7 @@ getElapsedMs, getJoystickInput, moveSender, - onGameStart, - onGameEnd, - onBombPlacedFromOthers, - onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork, - onBombExploded, + eventPorts, factories, }: GameSceneOrchestratorOptions) { this.app = app; @@ -131,12 +126,7 @@ this.getElapsedMs = getElapsedMs; this.getJoystickInput = getJoystickInput; this.moveSender = moveSender; - this.onGameStart = onGameStart; - this.onGameEnd = onGameEnd; - this.onBombPlacedFromOthers = onBombPlacedFromOthers; - this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; - this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.onBombExploded = onBombExploded; + this.eventPorts = eventPorts; this.createNetworkSync = factories?.createNetworkSync ?? ((options) => new GameNetworkSync(options)); this.createBombManager = factories?.createBombManager ?? ((options) => new BombManager(options)); this.createGameLoop = factories?.createGameLoop ?? ((options) => new GameLoop(options)); @@ -172,11 +162,11 @@ myId: this.myId, gameMap, appearanceResolver: this.appearanceResolver, - onGameStart: this.onGameStart, - onGameEnd: this.onGameEnd, - onBombPlacedFromOthers: this.onBombPlacedFromOthers, - onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork, + onGameStart: this.eventPorts.onGameStart, + onGameEnd: this.eventPorts.onGameEnd, + onBombPlacedFromOthers: this.eventPorts.onBombPlacedFromOthers, + onBombPlacedAckFromNetwork: this.eventPorts.onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork: this.eventPorts.onPlayerDeadFromNetwork, }); networkSync.bind(); return networkSync; @@ -190,7 +180,7 @@ myId: this.myId, getElapsedMs: this.getElapsedMs, appearanceResolver: this.appearanceResolver, - onBombExploded: this.onBombExploded, + onBombExploded: this.eventPorts.onBombExploded, }); } diff --git a/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts new file mode 100644 index 0000000..b9b8cec --- /dev/null +++ b/apps/client/src/scenes/game/application/presentation/GameUiPresenter.ts @@ -0,0 +1,25 @@ +/** + * GameUiPresenter + * ゲーム画面の表示用データ変換を提供する + * 残り時間と開始カウントダウンの表示生成を扱う + */ +import { config } from "@client/config"; + +/** 残り秒数を mm:ss 形式へ変換する */ +export const formatRemainingTime = (remainingSec: number): string => { + const mins = Math.floor(remainingSec / 60); + const secs = Math.floor(remainingSec % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +}; + +/** 開始カウントダウン表示文字列を生成する */ +export const buildStartCountdownText = ( + remainingSec: number, +): string | null => { + return remainingSec > 0 ? String(remainingSec) : null; +}; + +/** ゲーム画面の初期残り時間表示を返す */ +export const getInitialTimeDisplay = (): string => { + return formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); +}; \ 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 245113b..224ea18 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -4,20 +4,20 @@ * 入力状態,ネットワーク同期,ループ更新の実行責務を集約する */ import { Application, Container, Ticker } from "pixi.js"; -import type { - BombPlacedAckPayload, - BombPlacedPayload, - PlayerDeadPayload, -} from "@repo/shared"; import { AppearanceResolver } from "../AppearanceResolver"; import { GameNetworkSync } from "../GameNetworkSync"; import { GameLoop } from "../GameLoop"; -import { GameSceneOrchestrator, type GameSceneFactoryOptions } from "../orchestrators/GameSceneOrchestrator"; +import { + GameSceneOrchestrator, + type GameSceneEventPorts, + type GameSceneFactoryOptions, +} from "../orchestrators/GameSceneOrchestrator"; import type { GamePlayers } from "../game.types"; -import type { BombExplodedPayload, BombManager } from "../../entities/bomb/BombManager"; +import type { BombManager } from "../../entities/bomb/BombManager"; 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; @@ -28,12 +28,7 @@ gameActionSender: GameActionSender; moveSender: MoveSender; getElapsedMs: () => number; - onGameStart: (startTime: number) => void; - onGameEnd: () => void; - onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - onBombExploded: (payload: BombExplodedPayload) => void; + eventPorts: GameSceneEventPorts; sceneFactories?: GameSceneFactoryOptions; }; @@ -47,13 +42,9 @@ private readonly gameActionSender: GameActionSender; private readonly moveSender: MoveSender; private readonly getElapsedMs: () => number; - private readonly onGameStart: (startTime: number) => void; - private readonly onGameEnd: () => void; - private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private readonly onBombExploded: (payload: BombExplodedPayload) => void; + private readonly eventPorts: GameSceneEventPorts; private readonly sceneFactories?: GameSceneFactoryOptions; + private readonly disposableRegistry = new DisposableRegistry(); private readonly appearanceResolver = new AppearanceResolver(); private bombManager: BombManager | null = null; @@ -70,12 +61,7 @@ gameActionSender, moveSender, getElapsedMs, - onGameStart, - onGameEnd, - onBombPlacedFromOthers, - onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork, - onBombExploded, + eventPorts, sceneFactories, }: GameSceneRuntimeOptions) { this.app = app; @@ -86,13 +72,15 @@ this.gameActionSender = gameActionSender; this.moveSender = moveSender; this.getElapsedMs = getElapsedMs; - this.onGameStart = onGameStart; - this.onGameEnd = onGameEnd; - this.onBombPlacedFromOthers = onBombPlacedFromOthers; - this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; - this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.onBombExploded = onBombExploded; + this.eventPorts = eventPorts; this.sceneFactories = sceneFactories; + + this.disposableRegistry.add(() => { + this.clearJoystickInput(); + }); + this.disposableRegistry.add(() => { + this.gameLoop = null; + }); } /** シーン実行に必要なサブシステムを初期化する */ @@ -106,12 +94,7 @@ getElapsedMs: this.getElapsedMs, getJoystickInput: () => this.joystickInput, moveSender: this.moveSender, - onGameStart: this.onGameStart, - onGameEnd: this.onGameEnd, - onBombPlacedFromOthers: this.onBombPlacedFromOthers, - onBombPlacedAckFromNetwork: this.onBombPlacedAckFromNetwork, - onPlayerDeadFromNetwork: this.onPlayerDeadFromNetwork, - onBombExploded: this.onBombExploded, + eventPorts: this.eventPorts, factories: this.sceneFactories, }); @@ -119,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 { @@ -157,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 new file mode 100644 index 0000000..3f23c7c --- /dev/null +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -0,0 +1,105 @@ +/** + * GameUiStateSyncService + * ゲーム画面向けUI状態の購読と差分通知を管理する + * 秒境界に揃えた定期通知と購読解除を提供する + */ +import { + SYSTEM_TIME_PROVIDER, + type TimeProvider, +} from "@client/scenes/game/application/time/TimeProvider"; + +const UI_STATE_SECOND_MS = 1000; + +/** ゲーム画面UIへ通知する状態スナップショット */ +export type GameUiState = { + remainingTimeSec: number; + startCountdownSec: number; + isInputEnabled: boolean; +}; + +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, timeProvider }: GameUiStateSyncServiceOptions) { + this.getSnapshot = getSnapshot; + this.timeProvider = timeProvider ?? SYSTEM_TIME_PROVIDER; + } + + public subscribe(listener: (state: GameUiState) => void): () => void { + this.listeners.add(listener); + listener(this.getSnapshot()); + + return () => { + this.listeners.delete(listener); + }; + } + + public emitIfChanged(force = false): void { + if (this.listeners.size === 0 && !force) { + return; + } + + const snapshot = this.getSnapshot(); + if ( + !force + && this.lastState + && this.lastState.remainingTimeSec === snapshot.remainingTimeSec + && this.lastState.startCountdownSec === snapshot.startCountdownSec + && this.lastState.isInputEnabled === snapshot.isInputEnabled + ) { + return; + } + + this.lastState = snapshot; + this.listeners.forEach((listener) => { + listener(snapshot); + }); + } + + public startTicker(): void { + if (this.alignTimeoutId !== null || this.timerId !== null) { + return; + } + + const nowMs = this.timeProvider.now(); + const delayToNextSecond = UI_STATE_SECOND_MS - (nowMs % UI_STATE_SECOND_MS); + + this.alignTimeoutId = window.setTimeout(() => { + this.alignTimeoutId = null; + this.emitIfChanged(); + this.timerId = window.setInterval(() => { + this.emitIfChanged(); + }, UI_STATE_SECOND_MS); + }, delayToNextSecond); + } + + public stopTicker(): void { + if (this.alignTimeoutId !== null) { + window.clearTimeout(this.alignTimeoutId); + this.alignTimeoutId = null; + } + + if (this.timerId === null) { + return; + } + + window.clearInterval(this.timerId); + this.timerId = null; + } + + public clear(): void { + this.listeners.clear(); + this.lastState = null; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index 421751d..4a1ff5b 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -4,17 +4,12 @@ * Pixi描画領域,残り時間表示,入力橋渡しを提供する */ import { useCallback, useEffect, useRef, useState } from "react"; -import { config } from "@client/config"; import { GameManager } from "@client/scenes/game/GameManager"; - -const formatRemainingTime = (remaining: number) => { - const mins = Math.floor(remaining / 60); - const secs = Math.floor(remaining % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; -}; - -const getInitialTimeDisplay = () => - formatRemainingTime(config.GAME_CONFIG.GAME_DURATION_SEC); +import { + buildStartCountdownText, + formatRemainingTime, + getInitialTimeDisplay, +} from "@client/scenes/game/application/presentation/GameUiPresenter"; /** ゲーム画面の状態と入力ハンドラを提供するフック */ export const useGameSceneController = (myId: string | null) => { @@ -37,8 +32,7 @@ const nextDisplay = formatRemainingTime(state.remainingTimeSec); setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); - const remainingSec = state.startCountdownSec; - const nextCountdown = remainingSec > 0 ? String(remainingSec) : null; + const nextCountdown = buildStartCountdownText(state.startCountdownSec); setStartCountdownText((prev) => prev === nextCountdown ? prev : nextCountdown, ); diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index b1e7bb3..ec1df11 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -3,10 +3,10 @@ * ゲーム入力UIレイヤーを構成する * ジョイスティック層と爆弾ボタン層を分離して配置する */ -import { useEffect, useMemo, useState } from "react"; import { config } from "@client/config"; import { JoystickInputPresenter } from "./joystick/JoystickInputPresenter"; import { BombButton } from "./bomb/BombButton"; +import { useCooldownClock } from "./hooks/useCooldownClock"; /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { @@ -22,8 +22,6 @@ height: "100%", }; -const COOLDOWN_TICK_MS = 50; - /** 入力UIレイヤーを描画する */ export const GameInputOverlay = ({ isInputEnabled, @@ -31,54 +29,7 @@ onPlaceBomb, }: GameInputOverlayProps) => { const bombCooldownMs = config.GAME_CONFIG.BOMB_COOLDOWN_MS; - const [lastBombPressedAt, setLastBombPressedAt] = useState( - null, - ); - const [nowMs, setNowMs] = useState(() => Date.now()); - - useEffect(() => { - if (lastBombPressedAt === null) { - return; - } - - const timerId = window.setInterval(() => { - setNowMs(Date.now()); - }, COOLDOWN_TICK_MS); - - return () => { - window.clearInterval(timerId); - }; - }, [lastBombPressedAt]); - - const cooldownState = useMemo(() => { - if (bombCooldownMs <= 0) { - return { - progress: 1, - isReady: true, - remainingSecText: null, - }; - } - - if (lastBombPressedAt === null) { - return { - progress: 1, - isReady: true, - remainingSecText: null, - }; - } - - const elapsed = nowMs - lastBombPressedAt; - const clampedElapsed = Math.max(0, Math.min(elapsed, bombCooldownMs)); - const progress = clampedElapsed / bombCooldownMs; - const remainingMs = Math.max(0, bombCooldownMs - clampedElapsed); - const isReady = remainingMs === 0; - - return { - progress, - isReady, - remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)), - }; - }, [bombCooldownMs, lastBombPressedAt, nowMs]); + const { cooldownState, markTriggered } = useCooldownClock(bombCooldownMs); const handlePressBomb = () => { if (!isInputEnabled || !cooldownState.isReady) { @@ -90,8 +41,7 @@ return; } - setLastBombPressedAt(Date.now()); - setNowMs(Date.now()); + markTriggered(); }; return ( diff --git a/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts new file mode 100644 index 0000000..2a5243b --- /dev/null +++ b/apps/client/src/scenes/game/input/hooks/useCooldownClock.ts @@ -0,0 +1,84 @@ +/** + * useCooldownClock + * クールダウン経過率と残り秒表示を計算するフック + * トリガー時刻を保持し一定間隔で再計算する + */ +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; + +/** クールダウンUI描画に必要な状態 */ +export type CooldownState = { + progress: number; + isReady: boolean; + remainingSecText: string | null; +}; + +const READY_STATE: CooldownState = { + progress: 1, + isReady: true, + remainingSecText: null, +}; + +/** useCooldownClock の依存注入オプション */ +export type UseCooldownClockOptions = { + timeProvider?: TimeProvider; +}; + +/** クールダウン状態とトリガー操作を提供するフック */ +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(() => getNow()); + + useEffect(() => { + if (lastTriggeredAt === null || cooldownMs <= 0) { + return; + } + + const timerId = window.setInterval(() => { + setNowMs(getNow()); + }, COOLDOWN_TICK_MS); + + return () => { + window.clearInterval(timerId); + }; + }, [cooldownMs, getNow, lastTriggeredAt]); + + const cooldownState = useMemo(() => { + if (cooldownMs <= 0 || lastTriggeredAt === null) { + return READY_STATE; + } + + const elapsed = nowMs - lastTriggeredAt; + const clampedElapsed = Math.max(0, Math.min(elapsed, cooldownMs)); + const progress = clampedElapsed / cooldownMs; + const remainingMs = Math.max(0, cooldownMs - clampedElapsed); + const isReady = remainingMs === 0; + + return { + progress, + isReady, + remainingSecText: isReady ? null : String(Math.ceil(remainingMs / 1000)), + }; + }, [cooldownMs, lastTriggeredAt, nowMs]); + + const markTriggered = useCallback(() => { + const now = getNow(); + setLastTriggeredAt(now); + setNowMs(now); + }, [getNow]); + + return { + cooldownState, + markTriggered, + }; +}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/styles/GameView.styles.ts b/apps/client/src/scenes/game/styles/GameView.styles.ts new file mode 100644 index 0000000..5fb2e0a --- /dev/null +++ b/apps/client/src/scenes/game/styles/GameView.styles.ts @@ -0,0 +1,58 @@ +/** + * GameView.styles + * GameView の描画スタイル定数を集約する + * 画面全体レイアウトとオーバーレイ表示の見た目を定義する + */ +import type { CSSProperties } from "react"; + +/** ゲーム画面全体のルートスタイル */ +export const ROOT_STYLE: CSSProperties = { + width: "100vw", + height: "100vh", + overflow: "hidden", + position: "relative", + backgroundColor: "#000", + userSelect: "none", + WebkitUserSelect: "none", +}; + +/** 上部中央のタイマー表示スタイル */ +export const TIMER_STYLE: CSSProperties = { + position: "absolute", + top: "20px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 10, + color: "white", + fontSize: "32px", + fontWeight: "bold", + textShadow: "2px 2px 4px rgba(0,0,0,0.5)", + fontFamily: "monospace", + userSelect: "none", + WebkitUserSelect: "none", +}; + +/** Pixi描画レイヤーの配置スタイル */ +export const PIXI_LAYER_STYLE: CSSProperties = { + position: "absolute", + top: 0, + left: 0, + zIndex: 1, +}; + +/** 画面中央の開始カウントダウン表示スタイル */ +export const START_COUNTDOWN_STYLE: CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: 30, + color: "white", + fontSize: "clamp(3rem, 14vw, 8rem)", + fontWeight: 900, + textShadow: "0 0 16px rgba(0,0,0,0.85)", + fontFamily: "monospace", + userSelect: "none", + WebkitUserSelect: "none", + pointerEvents: "none", +}; \ No newline at end of file