Newer
Older
PixelPaintWar / apps / client / src / scenes / game / GameManager.ts
/**
 * GameManager
 * ゲーム全体の初期化,更新,破棄のライフサイクルを管理する
 * マップ,ネットワーク同期,ゲームループを統合する
 */
import { Application, Container, Ticker } from "pixi.js";
import { GameEventFacade } from "./application/GameEventFacade";
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 { registerGameManagerDisposers } from "./application/lifecycle/registerGameManagerDisposers";
import { type GameSceneFactoryOptions } from "./application/orchestrators/GameSceneOrchestrator";
import { GameSceneRuntime } from "./application/runtime/GameSceneRuntime";
import { GameManagerBootstrapper } from "./application/runtime/GameManagerBootstrapper";
import {
  SocketGameActionSender,
  type GameActionSender,
} from "./application/network/GameActionSender";
import {
  SocketPlayerMoveSender,
  type MoveSender,
} from "./application/network/PlayerMoveSender";
import type { GamePlayers } from "./application/game.types";
import type { HurricaneHitPayload } from "@repo/shared";
import {
  GameUiStateSyncService,
  type GameHudState,
  type MiniMapState,
  type GameUiState,
} from "./application/ui/GameUiStateSyncService";
import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader";

/** GameManager の依存注入オプション型 */
export type GameManagerDependencies = {
  sessionFacade?: GameSessionFacade;
  lifecycleState?: SceneLifecycleState;
  gameActionSender?: GameActionSender;
  moveSender?: MoveSender;
  sceneFactories?: GameSceneFactoryOptions;
};

/** GameScene の UI 表示状態型を外部参照向けに再公開する */
export type {
  GameUiState,
  GameHudState,
  MiniMapState,
} from "./application/ui/GameUiStateSyncService";

/** ゲームシーンの実行ライフサイクルを管理するマネージャー */
export class GameManager {
  private app: Application;
  private worldContainer: Container;
  private players: GamePlayers = {};
  private myId: string;
  private container: HTMLDivElement;
  private sessionFacade: GameSessionFacade;
  private runtime: GameSceneRuntime;
  private gameEventFacade: GameEventFacade;
  private combatFacade: CombatLifecycleFacade;
  private lifecycleState: SceneLifecycleState;
  private uiStateSyncService: GameUiStateSyncService;
  private disposableRegistry: DisposableRegistry;
  private localBombHitCount = 0;

  public getStartCountdownSec(): number {
    return this.sessionFacade.getStartCountdownSec();
  }

  // 現在の残り秒数を取得する
  public getRemainingTime(): number {
    return this.sessionFacade.getRemainingTime();
  }

  public isInputEnabled(): boolean {
    return this.runtime.isInputEnabled();
  }

  public placeBomb(): string | null {
    return this.runtime.placeBomb();
  }

  public lockInput(): () => void {
    this.runtime.clearJoystickInput();
    const release = this.sessionFacade.lockInput();
    this.uiStateSyncService.emitIfChanged();

    return () => {
      release();
      this.uiStateSyncService.emitIfChanged();
    };
  }

  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({
      onGameStarted: (startTime) => {
        // ゲーム開始カウントダウン中に先読みして初回被弾時の負荷を抑える
        preloadGameStartAssets();
        this.sessionFacade.setGameStart(startTime);
        this.uiStateSyncService.emitIfChanged();
      },
      getBombManager: () => this.runtime.getBombManager(),
    });
    this.combatFacade = new CombatLifecycleFacade({
      players: this.players,
      myId: this.myId,
      acquireInputLock: this.lockInput.bind(this),
      onSendBombHitReport: (bombId) => {
        gameActionSender.sendBombHitReport(bombId);
      },
      onLocalBombHitCountChanged: (count) => {
        this.localBombHitCount = count;
        this.uiStateSyncService.emitIfChanged();
      },
    });
    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(),
      eventPorts: {
        onGameStarted: this.gameEventFacade.applyGameStarted.bind(
          this.gameEventFacade,
        ),
        onGameEnded: this.lockInput.bind(this),
        onRemoteBombPlaced: (payload) => {
          this.gameEventFacade.applyRemoteBombPlaced(payload);
        },
        onBombPlacementAcknowledged: (payload) => {
          this.gameEventFacade.applyBombPlacementAcknowledged(payload);
        },
        onRemotePlayerHit: (payload) => {
          this.combatFacade.handleNetworkPlayerHit(payload);
        },
        onRemoteHurricaneHit: (payload: HurricaneHitPayload) => {
          this.combatFacade.handleNetworkHurricaneHit(payload);
        },
        onBombExploded: (payload) => {
          this.combatFacade.handleBombExploded(payload);
        },
      },
      sceneFactories,
    });
    this.uiStateSyncService = new GameUiStateSyncService({
      getSnapshot: () => this.getUiStateSnapshot(),
    });
    this.disposableRegistry = new DisposableRegistry();
    registerGameManagerDisposers({
      disposableRegistry: this.disposableRegistry,
      uiStateSyncService: this.uiStateSyncService,
      resetPlayers: () => {
        this.players = {};
      },
      sessionFacade: this.sessionFacade,
      combatFacade: this.combatFacade,
      runtime: this.runtime,
      lifecycleState: this.lifecycleState,
      app: this.app,
    });
  }

  /**
   * ゲームエンジンの初期化
   */
  public async init() {
    const bootstrapper = new GameManagerBootstrapper({
      app: this.app,
      lifecycleState: this.lifecycleState,
      container: this.container,
      runtime: this.runtime,
      tick: this.tick,
    });

    const result = await bootstrapper.bootstrap();
    if (!result.initialized) {
      return;
    }

    this.uiStateSyncService.startTicker();
    this.uiStateSyncService.emitIfChanged(true);
  }

  /**
   * React側からジョイスティックの入力を受け取る
   */
  public setJoystickInput(x: number, y: number) {
    this.runtime.setJoystickInput(x, y);
  }

  /**
   * 毎フレームの更新処理(メインゲームループ)
   */
  private tick = (ticker: Ticker) => {
    this.runtime.tick(ticker);
    this.uiStateSyncService.emitIfChanged();
  };

  /** UI状態購読を登録し,解除関数を返す */
  public subscribeUiState(listener: (state: GameUiState) => void): () => void {
    return this.uiStateSyncService.subscribe(listener);
  }

  /** HUD状態購読を登録し,解除関数を返す */
  public subscribeHudState(
    listener: (state: GameHudState) => void,
  ): () => void {
    return this.uiStateSyncService.subscribeHud(listener);
  }

  /** ミニマップ状態購読を登録し,解除関数を返す */
  public subscribeMiniMapState(
    listener: (state: MiniMapState) => void,
  ): () => void {
    return this.uiStateSyncService.subscribeMiniMap(listener);
  }

  private getUiStateSnapshot(): GameUiState {
    const miniMapTeamIds = this.runtime.getMiniMapTeamIds();

    return {
      hud: {
        remainingTimeSec: Math.floor(this.sessionFacade.getRemainingTime()),
        startCountdownSec: this.sessionFacade.getStartCountdownSec(),
        isInputEnabled: this.runtime.isInputEnabled(),
        teamPaintRates: this.runtime.getPaintRatesByTeam(),
        localBombHitCount: this.localBombHitCount,
      },
      miniMap: {
        mapRevision: this.runtime.getMiniMapRevision(),
        teamIds: miniMapTeamIds,
        localPlayerPosition: this.runtime.getLocalPlayerPosition(),
      },
    };
  }

  /**
   * クリーンアップ処理(コンポーネントアンマウント時)
   */
  public destroy() {
    this.lifecycleState.markDestroyed();
    this.disposableRegistry.disposeAll();
  }
}