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 5cd39fb..a250d36 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -4,21 +4,40 @@ * マップ,ネットワーク同期,ゲームループを統合する */ import { Application, Container, Ticker } from "pixi.js"; -import type { - BombPlacedAckPayload, - BombPlacedPayload, -} from "@repo/shared"; -import { socketManager } from "@client/network/SocketManager"; -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 } from "./application/orchestrators/GameSceneOrchestrator"; +import { + type GameSceneFactoryOptions, +} from "./application/orchestrators/GameSceneOrchestrator"; +import { GameSceneRuntime } from "./application/runtime/GameSceneRuntime"; +import { + SocketGameActionSender, + type GameActionSender, +} from "./application/network/GameActionSender"; +import { + SocketPlayerMoveSender, + type MoveSender, +} from "./application/network/PlayerMoveSender"; import type { GamePlayers } from "./application/game.types"; +/** GameManager の依存注入オプション型 */ +export type GameManagerDependencies = { + sessionFacade?: GameSessionFacade; + lifecycleState?: SceneLifecycleState; + gameActionSender?: GameActionSender; + moveSender?: MoveSender; + sceneFactories?: GameSceneFactoryOptions; +}; + +/** GameScene の UI 表示に必要な状態 */ +export type GameUiState = { + remainingTimeSec: number; + startCountdownSec: number; + isInputEnabled: boolean; +}; + /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ export class GameManager { private app: Application; @@ -26,18 +45,13 @@ private players: GamePlayers = {}; private myId: string; private container: HTMLDivElement; - private sessionFacade = new GameSessionFacade(); - private appearanceResolver = new AppearanceResolver(); - private bombManager: BombManager | null = null; - private networkSync: GameNetworkSync | null = null; - private gameLoop: GameLoop | null = null; + private sessionFacade: GameSessionFacade; + private runtime: GameSceneRuntime; + private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; - private lifecycleState = new SceneLifecycleState(); - - // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ - public setGameStart(startTime: number) { - this.sessionFacade.setGameStart(startTime); - } + private lifecycleState: SceneLifecycleState; + private uiStateListeners = new Set<(state: GameUiState) => void>(); + private lastUiState: GameUiState | null = null; public getStartCountdownSec(): number { return this.sessionFacade.getStartCountdownSec(); @@ -49,49 +63,74 @@ } 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; - - socketManager.game.sendPlaceBomb(placed.payload); - return placed.tempBombId; + return this.runtime.placeBomb(); } - public applyPlacedBombFromOthers(payload: BombPlacedPayload): void { - this.bombManager?.applyPlacedBombFromOthers(payload); - } - - public applyPlacedBombAck(payload: BombPlacedAckPayload): void { - this.bombManager?.applyPlacedBombAck(payload); - } - - // 入力と状態管理 - 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(container: HTMLDivElement, myId: string) { + constructor( + container: HTMLDivElement, + myId: string, + dependencies: GameManagerDependencies = {}, + ) { this.container = container; // 明示的に代入 this.myId = myId; + this.sessionFacade = dependencies.sessionFacade ?? new GameSessionFacade(); + this.lifecycleState = dependencies.lifecycleState ?? new SceneLifecycleState(); + 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; + this.gameEventFacade = new GameEventFacade({ + onGameStart: (startTime) => { + this.sessionFacade.setGameStart(startTime); + }, + getBombManager: () => this.runtime.getBombManager(), + }); this.combatFacade = new CombatLifecycleFacade({ players: this.players, myId: this.myId, acquireInputLock: this.lockInput.bind(this), onSendBombHitReport: (bombId) => { - socketManager.game.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, + }); } /** @@ -113,60 +152,70 @@ this.container.appendChild(this.app.canvas); - this.initializeSceneSubsystems(); + this.runtime.initialize(); // サーバーへゲーム準備完了を通知 - socketManager.game.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, - onGameStart: this.setGameStart.bind(this), - onGameEnd: this.lockInput.bind(this), - onBombPlacedFromOthers: (payload) => { - this.applyPlacedBombFromOthers(payload); - }, - onBombPlacedAckFromNetwork: (payload) => { - this.applyPlacedBombAck(payload); - }, - onPlayerDeadFromNetwork: (payload) => { - this.combatFacade.handleNetworkPlayerDead(payload); - }, - onBombExploded: (payload) => { - this.combatFacade.handleBombExploded(payload); - }, - }); + /** 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); + }); } /** @@ -177,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/GameEventFacade.ts b/apps/client/src/scenes/game/application/GameEventFacade.ts new file mode 100644 index 0000000..065b982 --- /dev/null +++ b/apps/client/src/scenes/game/application/GameEventFacade.ts @@ -0,0 +1,39 @@ +/** + * GameEventFacade + * ゲーム中に受信したイベントの適用窓口を提供する + * セッション開始同期と爆弾反映の処理を集約する + */ +import type { BombPlacedAckPayload, BombPlacedPayload } from "@repo/shared"; +import type { BombManager } from "../entities/bomb/BombManager"; + +/** GameEventFacade の初期化入力 */ +export type GameEventFacadeOptions = { + onGameStart: (startTime: number) => void; + getBombManager: () => BombManager | null; +}; + +/** 受信イベントの適用窓口を提供する */ +export class GameEventFacade { + private readonly onGameStart: (startTime: number) => void; + private readonly getBombManager: () => BombManager | null; + + constructor({ onGameStart, getBombManager }: GameEventFacadeOptions) { + this.onGameStart = onGameStart; + this.getBombManager = getBombManager; + } + + /** サーバー同期のゲーム開始時刻を適用する */ + public handleGameStart(startTime: number): void { + this.onGameStart(startTime); + } + + /** 他プレイヤー爆弾設置を反映する */ + public handleBombPlacedFromOthers(payload: BombPlacedPayload): void { + this.getBombManager()?.applyPlacedBombFromOthers(payload); + } + + /** 爆弾設置ACKを反映する */ + public handleBombPlacedAck(payload: BombPlacedAckPayload): void { + this.getBombManager()?.applyPlacedBombAck(payload); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 1e23d4e..1ac43ba 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -13,7 +13,7 @@ import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; import { resolveFrameDelta } from "./loopSteps/frameDelta"; -import { SocketPlayerMoveSender } from "./network/PlayerMoveSender"; +import type { MoveSender } from "./network/PlayerMoveSender"; type GameLoopOptions = { app: Application; @@ -22,6 +22,7 @@ myId: string; getJoystickInput: () => { x: number; y: number }; bombManager: BombManager; + moveSender: MoveSender; }; /** ゲームのフレーム更新順序を管理するループ制御クラス */ @@ -35,14 +36,14 @@ private bombStep: BombStep; private cameraStep: CameraStep; - constructor({ app, worldContainer, players, myId, getJoystickInput, bombManager }: GameLoopOptions) { + constructor({ app, worldContainer, players, myId, getJoystickInput, bombManager, moveSender }: GameLoopOptions) { this.app = app; this.worldContainer = worldContainer; this.players = players; this.myId = myId; this.inputStep = new InputStep({ getJoystickInput }); this.simulationStep = new SimulationStep({ - moveSender: new SocketPlayerMoveSender(), + moveSender, }); this.bombStep = new BombStep({ bombManager }); this.cameraStep = new CameraStep(); diff --git a/apps/client/src/scenes/game/application/network/GameActionSender.ts b/apps/client/src/scenes/game/application/network/GameActionSender.ts new file mode 100644 index 0000000..8c22eb2 --- /dev/null +++ b/apps/client/src/scenes/game/application/network/GameActionSender.ts @@ -0,0 +1,32 @@ +/** + * GameActionSender + * ゲーム中の送信アクションを提供する + * マネージャー層からソケット実装を分離する + */ +import type { PlaceBombPayload } from "@repo/shared"; +import { socketManager } from "@client/network/SocketManager"; + +/** ゲーム中送信アクションのインターフェース型 */ +export type GameActionSender = { + readyForGame: () => void; + sendPlaceBomb: (payload: PlaceBombPayload) => void; + sendBombHitReport: (bombId: string) => void; +}; + +/** ソケット経由でゲーム中送信アクションを実行する実装 */ +export class SocketGameActionSender implements GameActionSender { + /** ゲーム準備完了をサーバーへ送信する */ + public readyForGame(): void { + socketManager.game.readyForGame(); + } + + /** 爆弾設置要求をサーバーへ送信する */ + public sendPlaceBomb(payload: PlaceBombPayload): void { + socketManager.game.sendPlaceBomb(payload); + } + + /** 被弾報告をサーバーへ送信する */ + public sendBombHitReport(bombId: string): void { + socketManager.game.sendBombHitReport({ bombId }); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index 427b399..8e43682 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -14,8 +14,51 @@ import { GameNetworkSync } from "@client/scenes/game/application/GameNetworkSync"; import { BombManager, type BombExplodedPayload } from "@client/scenes/game/entities/bomb/BombManager"; import { GameLoop } from "@client/scenes/game/application/GameLoop"; +import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; +/** GameNetworkSync 生成入力型 */ +export type CreateNetworkSyncOptions = { + worldContainer: Container; + players: GamePlayers; + myId: string; + gameMap: GameMapController; + appearanceResolver: AppearanceResolver; + onGameStart: (startTime: number) => void; + onGameEnd: () => void; + onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; +}; + +/** BombManager 生成入力型 */ +export type CreateBombManagerOptions = { + worldContainer: Container; + players: GamePlayers; + myId: string; + getElapsedMs: () => number; + appearanceResolver: AppearanceResolver; + onBombExploded: (payload: BombExplodedPayload) => void; +}; + +/** GameLoop 生成入力型 */ +export type CreateGameLoopOptions = { + app: Application; + worldContainer: Container; + players: GamePlayers; + myId: string; + getJoystickInput: () => { x: number; y: number }; + bombManager: BombManager; + moveSender: MoveSender; +}; + +/** サブシステム生成関数群の注入型 */ +export type GameSceneFactoryOptions = { + createNetworkSync?: (options: CreateNetworkSyncOptions) => GameNetworkSync; + createBombManager?: (options: CreateBombManagerOptions) => BombManager; + createGameLoop?: (options: CreateGameLoopOptions) => GameLoop; +}; + /** GameSceneOrchestrator の初期化入力 */ export type GameSceneOrchestratorOptions = { app: Application; @@ -25,12 +68,14 @@ appearanceResolver: AppearanceResolver; 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; + factories?: GameSceneFactoryOptions; }; /** 初期化済みサブシステム参照の戻り値型 */ @@ -50,12 +95,16 @@ private readonly appearanceResolver: AppearanceResolver; 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 createNetworkSync: (options: CreateNetworkSyncOptions) => GameNetworkSync; + private readonly createBombManager: (options: CreateBombManagerOptions) => BombManager; + private readonly createGameLoop: (options: CreateGameLoopOptions) => GameLoop; constructor({ app, @@ -65,12 +114,14 @@ appearanceResolver, getElapsedMs, getJoystickInput, + moveSender, onGameStart, onGameEnd, onBombPlacedFromOthers, onBombPlacedAckFromNetwork, onPlayerDeadFromNetwork, onBombExploded, + factories, }: GameSceneOrchestratorOptions) { this.app = app; this.worldContainer = worldContainer; @@ -79,12 +130,16 @@ this.appearanceResolver = appearanceResolver; 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.createNetworkSync = factories?.createNetworkSync ?? ((options) => new GameNetworkSync(options)); + this.createBombManager = factories?.createBombManager ?? ((options) => new BombManager(options)); + this.createGameLoop = factories?.createGameLoop ?? ((options) => new GameLoop(options)); } /** シーン配線を順序どおり初期化し,参照を返す */ @@ -111,7 +166,7 @@ /** ネットワーク購読を初期化してバインドする */ private initializeNetworkSync(gameMap: GameMapController): GameNetworkSync { - const networkSync = new GameNetworkSync({ + const networkSync = this.createNetworkSync({ worldContainer: this.worldContainer, players: this.players, myId: this.myId, @@ -129,7 +184,7 @@ /** 爆弾サブシステムを初期化する */ private initializeBombSubsystem(): BombManager { - return new BombManager({ + return this.createBombManager({ worldContainer: this.worldContainer, players: this.players, myId: this.myId, @@ -141,13 +196,14 @@ /** ゲームループを初期化する */ private initializeGameLoop(bombManager: BombManager): GameLoop { - return new GameLoop({ + return this.createGameLoop({ app: this.app, worldContainer: this.worldContainer, players: this.players, myId: this.myId, getJoystickInput: this.getJoystickInput, bombManager, + moveSender: this.moveSender, }); } } \ 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 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 {