Newer
Older
PixelPaintWar / apps / server / src / domains / game / loop / hurricane / HurricaneSyncService.ts
/**
 * HurricaneSyncService
 * ハリケーンの同期ペイロード生成を担当する
 */
import { config } from "@server/config";
import { collectSyncDeltaEntries } from "@server/common/syncDelta";
import type { HurricaneStatePayload } from "@repo/shared";
import type {
  HurricaneState,
  HurricaneSyncOutputs,
  HurricaneSyncSnapshot,
} from "./hurricaneTypes.js";

const quantizeValue = (value: number, scale: number): number => {
  return Math.round(value * scale) / scale;
};

const toHurricaneSyncSnapshot = (
  state: HurricaneState,
): HurricaneSyncSnapshot => {
  return {
    x: quantizeValue(
      state.x,
      config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE,
    ),
    y: quantizeValue(
      state.y,
      config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE,
    ),
    radius: quantizeValue(
      state.radius,
      config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE,
    ),
    rotationRad: quantizeValue(
      state.rotationRad,
      config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_ROTATION_QUANTIZE_SCALE,
    ),
  };
};

const isSameHurricaneSyncSnapshot = (
  left: HurricaneSyncSnapshot,
  right: HurricaneSyncSnapshot,
): boolean => {
  return (
    left.x === right.x
    && left.y === right.y
    && left.radius === right.radius
    && left.rotationRad === right.rotationRad
  );
};

/** ハリケーン同期データを生成する */
export class HurricaneSyncService {
  private hasInitialSyncPending = false;
  private readonly lastSentSnapshotByHurricaneId = new Map<
    string,
    HurricaneSyncSnapshot
  >();

  /** 初回全量同期を次回生成するようマークする */
  public markInitialSyncPending(): void {
    this.hasInitialSyncPending = true;
  }

  /** 1ティック分の current/update 同期配列を返す */
  public consumeSyncOutputs(
    _elapsedMs: number,
    hurricanes: HurricaneState[],
  ): HurricaneSyncOutputs {
    const currentUpdates = this.consumeCurrentUpdates(hurricanes);
    const updateUpdates = this.consumeUpdateUpdates(hurricanes);

    return {
      currentUpdates,
      updateUpdates,
    };
  }

  /** 同期状態を初期化する */
  public clear(): void {
    this.hasInitialSyncPending = false;
    this.lastSentSnapshotByHurricaneId.clear();
  }

  /** current-hurricanes 用の初回全量同期を返す */
  private consumeCurrentUpdates(hurricanes: HurricaneState[]): HurricaneStatePayload[] {
    if (hurricanes.length === 0) {
      return [];
    }

    if (this.hasInitialSyncPending) {
      this.hasInitialSyncPending = false;
      return this.buildSnapshotPayloadAndCommit(hurricanes);
    }

    return [];
  }

  /** 差分同期配信用のハリケーン状態配列を返す */
  private consumeUpdateUpdates(
    hurricanes: HurricaneState[],
  ): HurricaneStatePayload[] {
    return collectSyncDeltaEntries(
      hurricanes,
      this.lastSentSnapshotByHurricaneId,
      {
        selectId: (hurricane) => hurricane.id,
        toSnapshot: (hurricane) => toHurricaneSyncSnapshot(hurricane),
        isSameSnapshot: (left, right) =>
          isSameHurricaneSyncSnapshot(left, right),
      },
    ).map((entry) => {
      const { item, snapshot } = entry;
      return {
        id: item.id,
        x: snapshot.x,
        y: snapshot.y,
        radius: snapshot.radius,
        rotationRad: snapshot.rotationRad,
      };
    });
  }

  /** 現在状態を量子化して送信済み状態へ反映する */
  private buildSnapshotPayloadAndCommit(
    hurricanes: HurricaneState[],
  ): HurricaneStatePayload[] {
    return hurricanes.map((hurricane) => {
      const snapshot = toHurricaneSyncSnapshot(hurricane);
      this.lastSentSnapshotByHurricaneId.set(hurricane.id, snapshot);

      return {
        id: hurricane.id,
        x: snapshot.x,
        y: snapshot.y,
        radius: snapshot.radius,
        rotationRad: snapshot.rotationRad,
      };
    });
  }
}