Newer
Older
PixelPaintWar / apps / client / src / scenes / game / entities / player / PlayerModel.ts
/**
 * PlayerModel
 * プレイヤーの座標計算と補間計算を担うモデル
 * ローカル移動,リモート目標座標,送信スナップショットを管理する
 */
import { config } from "@client/config";
import type { domain } from "@repo/shared";

/** プレイヤーの座標計算と補間計算を管理するモデル */
export class PlayerModel {
  public readonly id: string;
  public readonly name: string;
  public readonly teamId: number;

  private gridX: number;
  private gridY: number;
  private targetGridX: number;
  private targetGridY: number;

  /** 共有プレイヤー情報から初期状態を構築する */
  constructor(data: domain.player.PlayerData) {
    this.id = data.id;
    this.name = data.name;
    this.teamId = data.teamId;
    this.gridX = data.x;
    this.gridY = data.y;
    this.targetGridX = data.x;
    this.targetGridY = data.y;
  }

  /** 現在座標を取得する */
  public getPosition(): domain.player.MovePayload {
    return { x: this.gridX, y: this.gridY };
  }

  /** 送信用スナップショットを取得する */
  public getSnapshot(): domain.player.PlayerData {
    return {
      id: this.id,
      name: this.name,
      teamId: this.teamId,
      x: this.gridX,
      y: this.gridY,
    };
  }

  /** ローカル入力に基づいて座標を更新する */
  public moveLocal(vx: number, vy: number, deltaTime: number): void {
    if (
      !this.isFiniteNumber(vx) ||
      !this.isFiniteNumber(vy) ||
      !this.isFiniteNumber(deltaTime)
    ) {
      return;
    }

    const { PLAYER_SPEED } = config.GAME_CONFIG;
    const speed = PLAYER_SPEED * deltaTime;

    this.gridX += vx * speed;
    this.gridY += vy * speed;

    this.clampToBounds();
  }

  /** リモート更新の目標座標を設定する */
  public setRemoteTarget(update: Partial<domain.player.MovePayload>): void {
    if (update.x !== undefined && this.isFiniteNumber(update.x))
      this.targetGridX = update.x;
    if (update.y !== undefined && this.isFiniteNumber(update.y))
      this.targetGridY = update.y;
  }

  /** 目標座標に向けて補間更新する */
  public updateRemoteLerp(deltaTime: number): void {
    if (!this.isFiniteNumber(deltaTime)) {
      return;
    }

    const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } =
      config.GAME_CONFIG;

    const diffX = this.targetGridX - this.gridX;
    const diffY = this.targetGridY - this.gridY;

    if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) {
      this.gridX = this.targetGridX;
    } else {
      this.gridX += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime;
    }

    if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) {
      this.gridY = this.targetGridY;
    } else {
      this.gridY += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime;
    }
  }

  /** マップ境界内へ座標をクランプする */
  private clampToBounds(): void {
    const { GRID_COLS, GRID_ROWS, PLAYER_RADIUS } = config.GAME_CONFIG;

    this.gridX = Math.max(
      PLAYER_RADIUS,
      Math.min(GRID_COLS - PLAYER_RADIUS, this.gridX),
    );
    this.gridY = Math.max(
      PLAYER_RADIUS,
      Math.min(GRID_ROWS - PLAYER_RADIUS, this.gridY),
    );
  }

  /** 有限数かどうかを判定する */
  private isFiniteNumber(value: number): boolean {
    return Number.isFinite(value);
  }
}