Newer
Older
PixelPaintWar / apps / client / src / managers / GameManager.ts
import { Application, Container, Ticker } from "pixi.js";
import { socketClient } from "../network/SocketClient";
import { config } from "@repo/shared";
import type { playerTypes } from "@repo/shared";
import { BasePlayer, LocalPlayer, RemotePlayer } from "../entities/Player";
import { GameMap } from "../entities/GameMap";
import { MAX_DIST } from "../input/VirtualJoystick";

export class GameManager {
  private app: Application;
  private worldContainer: Container;
  private players: Record<string, BasePlayer> = {};
  private myId: string;
  private container: HTMLDivElement;
  private gameMap!: GameMap;
  private gameStartTime: number | null = null;

  // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ
  public setGameStart(startTime: number) {
    this.gameStartTime = startTime;
  }

  // 現在の残り秒数を取得する
  public getRemainingTime(): number {
    if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC;
    
    // 現在のサーバー時刻を推定 (Date.now() + サーバーとの誤差補正が理想ですが、まずは簡易版で)
    const elapsedMs = Date.now() - this.gameStartTime;
    const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - (elapsedMs / 1000);
    
    return Math.max(0, remainingSec);
  }
  
  // 入力と状態管理
  private joystickInput = { x: 0, y: 0 };
  private lastPositionSentTime = 0;
  private wasMoving = false;
  private isInitialized = false;
  private isDestroyed = false;

  constructor(container: HTMLDivElement, myId: string) {
    this.container = container; // 明示的に代入
    this.myId = myId;
    this.app = new Application();
    this.worldContainer = new Container();
  }

  /**
   * ゲームエンジンの初期化
   */
  public async init() {
    // PixiJS本体の初期化
    await this.app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true });

    // 初期化完了前に destroy() が呼ばれていたら、ここで処理を中断して破棄する
    if (this.isDestroyed) {
        this.app.destroy(true, { children: true });
        return;
    }

    this.container.appendChild(this.app.canvas);

    // 背景マップの配置
    const gameMap = new GameMap();
    this.gameMap = gameMap;
    this.worldContainer.addChild(gameMap);
    this.app.stage.addChild(this.worldContainer);

    // ネットワークイベントの登録
    this.setupSocketListeners();

    // サーバーへゲーム準備完了を通知
    socketClient.readyForGame();

    // メインループの登録
    this.app.ticker.add(this.tick.bind(this));
    this.isInitialized = true;
  }

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

  /**
   * ソケットイベントの登録
   */
  private setupSocketListeners() {
    socketClient.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record<string, playerTypes.PlayerData>) => {
      const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.PlayerData[];
      playersArray.forEach((p) => {
        const playerSprite = p.id === this.myId ? new LocalPlayer(p) : new RemotePlayer(p);
        this.worldContainer.addChild(playerSprite);
        this.players[p.id] = playerSprite;
      });
    });

    socketClient.onNewPlayer((p: playerTypes.PlayerData) => {
      const playerSprite = new RemotePlayer(p);
      this.worldContainer.addChild(playerSprite);
      this.players[p.id] = playerSprite;
    });

    // サーバーからの GAME_START を検知して開始時刻をセットする
    socketClient.onGameStart((data) => {
      if (data && data.startTime) {
        this.setGameStart(data.startTime);
        console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`);
      }
    });

    socketClient.onUpdatePlayer((data: Partial<playerTypes.PlayerData> & { id: string }) => {
      if (data.id === this.myId) return;
      const target = this.players[data.id];
      if (target && target instanceof RemotePlayer) {
        target.setTargetPosition(data.x, data.y);
      }
    });

    socketClient.onRemovePlayer((id: string) => {
      const target = this.players[id];
      if (target) {
        this.worldContainer.removeChild(target);
        target.destroy();
        delete this.players[id];
      }
    });

    socketClient.onUpdateMapCells((updates) => {
      this.gameMap.updateCells(updates);
    });
  }

  /**
   * 毎フレームの更新処理(メインゲームループ)
   */
  private tick(ticker: Ticker) {
    const me = this.players[this.myId];
    if (!me || !(me instanceof LocalPlayer)) return;

    // 1. 自プレイヤーの移動と送信
    const { x: dx, y: dy } = this.joystickInput;
    const isMoving = dx !== 0 || dy !== 0;

    if (isMoving) {
      me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime);
      
      const now = performance.now();
      if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) {
        socketClient.sendMove(me.x, me.y);
        this.lastPositionSentTime = now;
      }
    } else if (this.wasMoving) {
      socketClient.sendMove(me.x, me.y);
    }
    this.wasMoving = isMoving;

    // 2. 全プレイヤーの更新(Lerpなど)
    Object.values(this.players).forEach((player) => {
      player.update(ticker.deltaTime);
    });

    // 3. カメラの追従(自分を中心に)
    this.worldContainer.position.set(
      -(me.x - this.app.screen.width / 2),
      -(me.y - this.app.screen.height / 2)
    );
  }

  /**
   * クリーンアップ処理(コンポーネントアンマウント時)
   */
  public destroy() {
    this.isDestroyed = true;
    if (this.isInitialized) {
      this.app.destroy(true, { children: true });
    }
    this.players = {};
    
    // イベント購読の解除
    socketClient.removeAllListeners();
  }
}