Newer
Older
PixelPaintWar / apps / client / src / scenes / game / application / network / handlers / PlayerSyncHandler.ts
/**
 * PlayerSyncHandler
 * プレイヤー同期イベントの受信処理を担当する
 * 生成,更新,削除の適用を一元管理する
 */
import { Container } from "pixi.js";
import type {
  CurrentPlayerMetaPayload,
  CurrentPlayerBootstrapPayload,
  CurrentPlayersPayload,
  NewPlayerPayload,
  RemovePlayerPayload,
  UpdatePlayersPayload,
} from "@repo/shared";
import {
  RemotePlayerController,
} from "@client/scenes/game/entities/player/PlayerController";
import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository";
import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver";
import { PlayerControllerFactory } from "./PlayerControllerFactory";

/** PlayerSyncHandler の初期化入力 */
export type PlayerSyncHandlerOptions = {
  worldContainer: Container;
  playerRepository: PlayerRepository;
  myId: string;
  appearanceResolver: AppearanceResolver;
};

/** プレイヤー関連の同期イベント適用を担当する */
export class PlayerSyncHandler {
  private readonly worldContainer: Container;
  private readonly playerRepository: PlayerRepository;
  private readonly myId: string;
  private readonly playerControllerFactory: PlayerControllerFactory;

  constructor({ worldContainer, playerRepository, myId, appearanceResolver }: PlayerSyncHandlerOptions) {
    this.worldContainer = worldContainer;
    this.playerRepository = playerRepository;
    this.myId = myId;
    this.playerControllerFactory = new PlayerControllerFactory({
      myId,
      appearanceResolver,
    });
  }

  /** 初期プレイヤー一覧を受信し,座標付き要素のみ実体生成する */
  public handleCurrentPlayers = (serverPlayers: CurrentPlayersPayload): void => {
    serverPlayers.forEach((player) => {
      this.upsertFromCurrentPlayerBootstrapPayload(player);
    });
  };

  /** 新規参加プレイヤーを生成して反映する */
  public handleNewPlayer = (payload: NewPlayerPayload): void => {
    this.upsertFromNewPlayerPayload(payload);
  };

  /** プレイヤー差分更新を反映する(自分自身は除外する) */
  public handlePlayerUpdates = (changedPlayers: UpdatePlayersPayload): void => {
    changedPlayers.forEach((playerData) => {
      if (playerData.id === this.myId) {
        return;
      }

      const target = this.playerRepository.getById(playerData.id);
      if (target && target instanceof RemotePlayerController) {
        target.applyRemoteUpdate({ x: playerData.x, y: playerData.y });
      }
    });
  };

  /** 退出プレイヤーを削除する */
  public handleRemovePlayer = (id: RemovePlayerPayload): void => {
    const target = this.playerRepository.remove(id);
    if (!target) {
      return;
    }

    this.worldContainer.removeChild(target.getDisplayObject());
    target.destroy();
  };

  private replacePlayerController(playerId: string, payload: NewPlayerPayload): void {
    const existing = this.playerRepository.remove(playerId);
    if (existing) {
      this.worldContainer.removeChild(existing.getDisplayObject());
      existing.destroy();
    }

    const playerController = this.playerControllerFactory.create(playerId, payload);

    this.worldContainer.addChild(playerController.getDisplayObject());
    this.playerRepository.upsert(playerId, playerController);
  }

  /** current-players要素から初期表示用の実体生成を行う */
  private upsertFromCurrentPlayerBootstrapPayload(
    payload: CurrentPlayerBootstrapPayload,
  ): void {
    if (!this.hasBootstrapPosition(payload)) {
      return;
    }

    this.upsertFromNewPlayerPayload({
      id: payload.id,
      name: payload.name,
      teamId: payload.teamId,
      x: payload.x,
      y: payload.y,
    });
  }

  /** current-players要素が初期表示座標を含むか判定する */
  private hasBootstrapPosition(
    payload: CurrentPlayerBootstrapPayload,
  ): payload is CurrentPlayerMetaPayload & Pick<NewPlayerPayload, "x" | "y"> {
    return "x" in payload && "y" in payload;
  }

  /** new-player情報から実体生成を行う */
  private upsertFromNewPlayerPayload(payload: NewPlayerPayload): void {
    this.replacePlayerController(payload.id, payload);
  }
}