diff --git a/apps/client/src/scenes/game/GameInputManager.ts b/apps/client/src/scenes/game/GameInputManager.ts deleted file mode 100644 index 3fde571..0000000 --- a/apps/client/src/scenes/game/GameInputManager.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * GameInputManager - * ゲーム側へ入力を集約して橋渡しする - */ -/** ジョイスティック入力をゲーム管理へ橋渡しするマネージャー */ -export class GameInputManager { - private onJoystickInput: (x: number, y: number) => void; - private onPlaceBomb: () => boolean; - - constructor( - onJoystickInput: (x: number, y: number) => void, - onPlaceBomb: () => boolean, - ) { - this.onJoystickInput = onJoystickInput; - this.onPlaceBomb = onPlaceBomb; - } - - public handleJoystickInput = (x: number, y: number) => { - this.onJoystickInput(x, y); - }; - - public handlePlaceBomb = (): boolean => { - return this.onPlaceBomb(); - }; -} diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 29b2433..a250d36 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -4,18 +4,14 @@ * マップ,ネットワーク同期,ゲームループを統合する */ import { Application, Container, Ticker } from "pixi.js"; -import { AppearanceResolver } from "./application/AppearanceResolver"; -import { BombManager } from "./entities/bomb/BombManager"; -import { GameNetworkSync } from "./application/GameNetworkSync"; -import { GameLoop } from "./application/GameLoop"; import { GameEventFacade } from "./application/GameEventFacade"; import { SceneLifecycleState } from "./application/lifecycle/SceneLifecycleState"; import { GameSessionFacade } from "./application/lifecycle/GameSessionFacade"; import { CombatLifecycleFacade } from "./application/combat/CombatLifecycleFacade"; import { - GameSceneOrchestrator, type GameSceneFactoryOptions, } from "./application/orchestrators/GameSceneOrchestrator"; +import { GameSceneRuntime } from "./application/runtime/GameSceneRuntime"; import { SocketGameActionSender, type GameActionSender, @@ -35,6 +31,13 @@ sceneFactories?: GameSceneFactoryOptions; }; +/** GameScene の UI 表示に必要な状態 */ +export type GameUiState = { + remainingTimeSec: number; + startCountdownSec: number; + isInputEnabled: boolean; +}; + /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ export class GameManager { private app: Application; @@ -43,16 +46,12 @@ private myId: string; private container: HTMLDivElement; private sessionFacade: GameSessionFacade; - private appearanceResolver = new AppearanceResolver(); - private bombManager: BombManager | null = null; - private networkSync: GameNetworkSync | null = null; - private gameLoop: GameLoop | null = null; + private runtime: GameSceneRuntime; private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; - private gameActionSender: GameActionSender; - private moveSender: MoveSender; - private sceneFactories?: GameSceneFactoryOptions; + private uiStateListeners = new Set<(state: GameUiState) => void>(); + private lastUiState: GameUiState | null = null; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -64,25 +63,18 @@ } public isInputEnabled(): boolean { - return this.sessionFacade.canAcceptInput(); + return this.runtime.isInputEnabled(); } public placeBomb(): string | null { - if (!this.sessionFacade.canAcceptInput()) return null; - if (!this.bombManager) return null; - const placed = this.bombManager.placeBomb(); - if (!placed) return null; - - this.gameActionSender.sendPlaceBomb(placed.payload); - return placed.tempBombId; + return this.runtime.placeBomb(); } - // 入力と状態管理 - private joystickInput = { x: 0, y: 0 }; - public lockInput(): () => void { - this.joystickInput = { x: 0, y: 0 }; - return this.sessionFacade.lockInput(); + this.runtime.clearJoystickInput(); + const release = this.sessionFacade.lockInput(); + this.emitUiStateIfChanged(true); + return release; } constructor( @@ -94,9 +86,9 @@ this.myId = myId; this.sessionFacade = dependencies.sessionFacade ?? new GameSessionFacade(); this.lifecycleState = dependencies.lifecycleState ?? new SceneLifecycleState(); - this.gameActionSender = dependencies.gameActionSender ?? new SocketGameActionSender(); - this.moveSender = dependencies.moveSender ?? new SocketPlayerMoveSender(); - this.sceneFactories = dependencies.sceneFactories; + const gameActionSender = dependencies.gameActionSender ?? new SocketGameActionSender(); + const moveSender = dependencies.moveSender ?? new SocketPlayerMoveSender(); + const sceneFactories = dependencies.sceneFactories; this.app = new Application(); this.worldContainer = new Container(); this.worldContainer.sortableChildren = true; @@ -104,16 +96,41 @@ onGameStart: (startTime) => { this.sessionFacade.setGameStart(startTime); }, - getBombManager: () => this.bombManager, + getBombManager: () => this.runtime.getBombManager(), }); this.combatFacade = new CombatLifecycleFacade({ players: this.players, myId: this.myId, acquireInputLock: this.lockInput.bind(this), onSendBombHitReport: (bombId) => { - this.gameActionSender.sendBombHitReport(bombId); + gameActionSender.sendBombHitReport(bombId); }, }); + this.runtime = new GameSceneRuntime({ + app: this.app, + worldContainer: this.worldContainer, + players: this.players, + myId: this.myId, + sessionFacade: this.sessionFacade, + 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); + }, + sceneFactories, + }); } /** @@ -135,62 +152,70 @@ this.container.appendChild(this.app.canvas); - this.initializeSceneSubsystems(); + this.runtime.initialize(); // サーバーへゲーム準備完了を通知 - this.gameActionSender.readyForGame(); + this.runtime.readyForGame(); // メインループの登録 this.app.ticker.add(this.tick); this.lifecycleState.markInitialized(); + this.emitUiStateIfChanged(true); } /** * React側からジョイスティックの入力を受け取る */ public setJoystickInput(x: number, y: number) { - this.joystickInput = this.sessionFacade.sanitizeJoystickInput({ x, y }); + this.runtime.setJoystickInput(x, y); } /** * 毎フレームの更新処理(メインゲームループ) */ private tick = (ticker: Ticker) => { - this.gameLoop?.tick(ticker); + this.runtime.tick(ticker); + this.emitUiStateIfChanged(); }; - /** ゲームシーンのサブシステムを初期化して配線する */ - private initializeSceneSubsystems(): void { - const orchestrator = new GameSceneOrchestrator({ - app: this.app, - worldContainer: this.worldContainer, - players: this.players, - myId: this.myId, - appearanceResolver: this.appearanceResolver, - getElapsedMs: () => this.sessionFacade.getElapsedMs(), - getJoystickInput: () => this.joystickInput, - moveSender: this.moveSender, - 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); - }, - factories: this.sceneFactories, - }); + /** UI状態購読を登録し,解除関数を返す */ + public subscribeUiState(listener: (state: GameUiState) => void): () => void { + this.uiStateListeners.add(listener); + listener(this.getUiStateSnapshot()); - const initializedScene = orchestrator.initialize(); - this.networkSync = initializedScene.networkSync; - this.bombManager = initializedScene.bombManager; - this.gameLoop = initializedScene.gameLoop; + return () => { + this.uiStateListeners.delete(listener); + }; + } + + private getUiStateSnapshot(): GameUiState { + return { + remainingTimeSec: Math.floor(this.sessionFacade.getRemainingTime()), + startCountdownSec: this.sessionFacade.getStartCountdownSec(), + isInputEnabled: this.runtime.isInputEnabled(), + }; + } + + 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); + }); } /** @@ -201,14 +226,11 @@ if (this.lifecycleState.shouldDestroyApp()) { this.app.destroy(true, { children: true }); } - this.bombManager?.destroy(); - this.bombManager = null; + this.runtime.destroy(); this.combatFacade.dispose(); this.sessionFacade.reset(); this.players = {}; - this.joystickInput = { x: 0, y: 0 }; - - // イベント購読の解除 - this.networkSync?.unbind(); + this.uiStateListeners.clear(); + this.lastUiState = null; } } diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts new file mode 100644 index 0000000..245113b --- /dev/null +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -0,0 +1,167 @@ +/** + * GameSceneRuntime + * ゲームシーン実行中のサブシステム管理を担当する + * 入力状態,ネットワーク同期,ループ更新の実行責務を集約する + */ +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 type { GamePlayers } from "../game.types"; +import type { BombExplodedPayload, BombManager } from "../../entities/bomb/BombManager"; +import type { MoveSender } from "../network/PlayerMoveSender"; +import type { GameActionSender } from "../network/GameActionSender"; +import type { GameSessionFacade } from "../lifecycle/GameSessionFacade"; + +export type GameSceneRuntimeOptions = { + app: Application; + worldContainer: Container; + players: GamePlayers; + myId: string; + sessionFacade: GameSessionFacade; + 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; + sceneFactories?: GameSceneFactoryOptions; +}; + +/** ゲームシーンの実行系サブシステムを管理する */ +export class GameSceneRuntime { + private readonly app: Application; + private readonly worldContainer: Container; + private readonly players: GamePlayers; + private readonly myId: string; + private readonly sessionFacade: GameSessionFacade; + 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 sceneFactories?: GameSceneFactoryOptions; + + private readonly appearanceResolver = new AppearanceResolver(); + private bombManager: BombManager | null = null; + private networkSync: GameNetworkSync | null = null; + private gameLoop: GameLoop | null = null; + private joystickInput = { x: 0, y: 0 }; + + constructor({ + app, + worldContainer, + players, + myId, + sessionFacade, + gameActionSender, + moveSender, + getElapsedMs, + onGameStart, + onGameEnd, + onBombPlacedFromOthers, + onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork, + onBombExploded, + sceneFactories, + }: GameSceneRuntimeOptions) { + this.app = app; + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.sessionFacade = sessionFacade; + 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.sceneFactories = sceneFactories; + } + + /** シーン実行に必要なサブシステムを初期化する */ + public initialize(): void { + const orchestrator = new GameSceneOrchestrator({ + app: this.app, + worldContainer: this.worldContainer, + players: this.players, + myId: this.myId, + appearanceResolver: this.appearanceResolver, + 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, + factories: this.sceneFactories, + }); + + const initializedScene = orchestrator.initialize(); + this.networkSync = initializedScene.networkSync; + this.bombManager = initializedScene.bombManager; + this.gameLoop = initializedScene.gameLoop; + } + + public isInputEnabled(): boolean { + return this.sessionFacade.canAcceptInput(); + } + + public setJoystickInput(x: number, y: number): void { + this.joystickInput = this.sessionFacade.sanitizeJoystickInput({ x, y }); + } + + public clearJoystickInput(): void { + this.joystickInput = { x: 0, y: 0 }; + } + + public placeBomb(): string | null { + if (!this.sessionFacade.canAcceptInput()) return null; + if (!this.bombManager) return null; + const placed = this.bombManager.placeBomb(); + if (!placed) return null; + + this.gameActionSender.sendPlaceBomb(placed.payload); + return placed.tempBombId; + } + + public getBombManager(): BombManager | null { + return this.bombManager; + } + + public readyForGame(): void { + this.gameActionSender.readyForGame(); + } + + public tick(ticker: Ticker): void { + this.gameLoop?.tick(ticker); + } + + /** 実行系サブシステムを破棄する */ + public destroy(): void { + this.bombManager?.destroy(); + this.bombManager = null; + this.networkSync?.unbind(); + this.networkSync = null; + this.gameLoop = null; + this.clearJoystickInput(); + } +} diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index 3e40bd6..421751d 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -5,7 +5,6 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; import { config } from "@client/config"; -import { GameInputManager } from "@client/scenes/game/GameInputManager"; import { GameManager } from "@client/scenes/game/GameManager"; const formatRemainingTime = (remaining: number) => { @@ -21,7 +20,6 @@ export const useGameSceneController = (myId: string | null) => { const pixiContainerRef = useRef(null); const gameManagerRef = useRef(null); - const inputManagerRef = useRef(null); const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); const [startCountdownText, setStartCountdownText] = useState( null, @@ -35,47 +33,37 @@ manager.init(); gameManagerRef.current = manager; - inputManagerRef.current = new GameInputManager( - (x, y) => { - manager.setJoystickInput(x, y); - }, - () => { - return manager.placeBomb() !== null; - }, - ); - - const timerInterval = setInterval(() => { - const nextDisplay = formatRemainingTime(manager.getRemainingTime()); + const unsubscribeUiState = manager.subscribeUiState((state) => { + const nextDisplay = formatRemainingTime(state.remainingTimeSec); setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); - const remainingSec = manager.getStartCountdownSec(); + const remainingSec = state.startCountdownSec; const nextCountdown = remainingSec > 0 ? String(remainingSec) : null; setStartCountdownText((prev) => prev === nextCountdown ? prev : nextCountdown, ); - const nextInputEnabled = manager.isInputEnabled(); + const nextInputEnabled = state.isInputEnabled; setIsInputEnabled((prev) => prev === nextInputEnabled ? prev : nextInputEnabled, ); - }, config.GAME_CONFIG.TIMER_DISPLAY_UPDATE_MS); + }); return () => { + unsubscribeUiState(); manager.destroy(); gameManagerRef.current = null; - inputManagerRef.current = null; - clearInterval(timerInterval); setStartCountdownText(null); setIsInputEnabled(false); }; }, [myId]); const handleInput = useCallback((x: number, y: number) => { - inputManagerRef.current?.handleJoystickInput(x, y); + gameManagerRef.current?.setJoystickInput(x, y); }, []); const handlePlaceBomb = useCallback((): boolean => { - return inputManagerRef.current?.handlePlaceBomb() ?? false; + return gameManagerRef.current?.placeBomb() !== null; }, []); return {