Newer
Older
PixelPaintWar / apps / client / src / scenes / game / entities / hurricane / HurricaneOverlayController.ts
/**
 * HurricaneOverlayController
 * ハリケーン状態配列を受け取り,Pixi描画オブジェクトへ反映する
 * 生成,更新,削除を同一コントローラーで管理する
 */
import type { HurricaneStatePayload, UpdateHurricanesPayload } from "@repo/shared";
import { config } from "@client/config";
import { Container, Sprite, Texture } from "pixi.js";
import { loadHurricaneTexture } from "./HurricaneTextureCache";
import {
  isCircleIntersectingViewport,
  type WorldViewport,
} from "@client/scenes/game/application/culling/worldViewport";

type HurricaneDisplay = {
  container: Container;
  sprite: Sprite;
  radiusGrid: number;
};

/** ハリケーン描画オーバーレイを管理する */
export class HurricaneOverlayController {
  private readonly layer: Container;
  private readonly displayById = new Map<string, HurricaneDisplay>();
  private readonly stateById = new Map<string, HurricaneStatePayload>();
  private readonly imageUrl = `${import.meta.env.BASE_URL}hurricane.svg`;

  constructor(worldContainer: Container) {
    this.layer = new Container();
    this.layer.sortableChildren = false;
    worldContainer.addChild(this.layer);
  }

  /** ハリケーン状態を描画へ同期する */
  public applyUpdates(states: UpdateHurricanesPayload): void {
    states.forEach((state) => {
      this.stateById.set(state.id, state);

      let target = this.displayById.get(state.id);
      if (!target) {
        const created = this.createDisplay();
        this.layer.addChild(created.container);
        this.displayById.set(state.id, created);
        target = created;
      }

      if (!target.container.visible) {
        return;
      }

      this.renderDisplayFromState(target, state);
    });
  }

  /** 受信状態で全体を置換し,未含有IDを描画から除去する */
  public replaceAll(states: UpdateHurricanesPayload): void {
    const nextIds = new Set(states.map((state) => state.id));

    this.displayById.forEach((display, id) => {
      if (nextIds.has(id)) {
        return;
      }

      this.layer.removeChild(display.container);
      display.container.destroy({ children: true });
      this.displayById.delete(id);
      this.stateById.delete(id);
    });

    this.applyUpdates(states);
  }

  /** 可視矩形に基づいてハリケーン表示を切り替える */
  public applyViewportCulling(viewport: WorldViewport, marginPx: number): void {
    this.stateById.forEach((state, id) => {
      const display = this.displayById.get(id);
      if (!display) {
        return;
      }

      const centerX = state.x * config.GAME_CONFIG.GRID_CELL_SIZE;
      const centerY = state.y * config.GAME_CONFIG.GRID_CELL_SIZE;
      const radiusPx = state.radius * config.GAME_CONFIG.GRID_CELL_SIZE + marginPx;
      const isVisible = isCircleIntersectingViewport(
        centerX,
        centerY,
        radiusPx,
        viewport,
      );

      const wasVisible = display.container.visible;
      display.container.visible = isVisible;

      if (isVisible && !wasVisible) {
        this.renderDisplayFromState(display, state);
      }
    });
  }

  /** 描画リソースを破棄する */
  public destroy(): void {
    this.displayById.forEach((display) => {
      this.layer.removeChild(display.container);
      display.container.destroy({ children: true });
    });
    this.displayById.clear();
    this.layer.destroy({ children: true });
  }

  private createDisplay(): HurricaneDisplay {
    const container = new Container();
    const sprite = new Sprite(Texture.WHITE);
    sprite.anchor.set(0.5, 0.5);
    this.applySpriteSize(sprite, 0);
    container.addChild(sprite);

    void this.applyTexture(sprite);

    return {
      container,
      sprite,
      radiusGrid: 0,
    };
  }

  /** ハリケーン画像を読み込んでスプライトへ適用する */
  private async applyTexture(sprite: Sprite): Promise<void> {
    try {
      const texture = await loadHurricaneTexture(this.imageUrl);
      sprite.texture = texture;
    } catch {
      // 読み込み失敗時は白テクスチャのまま描画を継続する
    }
  }

  /** 当たり判定半径に一致する見た目サイズを適用する */
  private applySpriteSize(sprite: Sprite, radiusGrid: number): void {
    const sizePx = this.toSpriteSizePx(radiusGrid);
    sprite.width = sizePx;
    sprite.height = sizePx;
  }

  /** 半径グリッド値をスプライト直径ピクセルへ変換する */
  private toSpriteSizePx(radiusGrid: number): number {
    return radiusGrid * 2 * config.GAME_CONFIG.GRID_CELL_SIZE;
  }

  /** 最新状態を描画オブジェクトへ反映する */
  private renderDisplayFromState(
    display: HurricaneDisplay,
    state: HurricaneStatePayload,
  ): void {
    if (Math.abs(display.radiusGrid - state.radius) > 0.0001) {
      this.applySpriteSize(display.sprite, state.radius);
      display.radiusGrid = state.radius;
    }

    display.container.x = state.x * config.GAME_CONFIG.GRID_CELL_SIZE;
    display.container.y = state.y * config.GAME_CONFIG.GRID_CELL_SIZE;
    display.container.rotation = state.rotationRad;
  }
}