Newer
Older
PixelPaintWar / apps / server / src / domains / game / loop / HurricaneSystem.ts
/**
 * HurricaneSystem
 * ハリケーンの生成,移動,同期データ化,被弾検知を管理する
 * GameLoop からハリケーン専用責務を分離する
 */
import { config } from "@server/config";
import { domain, type HurricaneStatePayload } from "@repo/shared";
import { Player } from "../entities/player/Player.js";

const { checkBombHit } = domain.game.bombHit;

type HurricaneState = {
  id: string;
  x: number;
  y: number;
  vx: number;
  vy: number;
  radius: number;
  rotationRad: number;
};

/** ハリケーン状態の生成更新と被弾判定を管理する */
export class HurricaneSystem {
  private hasSpawned = false;
  private hurricanes: HurricaneState[] = [];
  private readonly lastHitAtMsByPlayerId = new Map<string, number>();

  /** 残り時間しきい値到達時にハリケーンを一度だけ生成する */
  public ensureSpawned(elapsedMs: number): void {
    if (!config.GAME_CONFIG.HURRICANE_ENABLED || this.hasSpawned) {
      return;
    }

    const remainingSec =
      config.GAME_CONFIG.GAME_DURATION_SEC - elapsedMs / 1000;
    if (remainingSec > config.GAME_CONFIG.HURRICANE_SPAWN_REMAINING_SEC) {
      return;
    }

    this.hasSpawned = true;
    this.hurricanes = Array.from(
      { length: config.GAME_CONFIG.HURRICANE_COUNT },
      (_, index) => this.createHurricane(index),
    );
  }

  /** ハリケーンを直線移動させ,境界で反射させる */
  public update(deltaSec: number): void {
    if (this.hurricanes.length === 0) {
      return;
    }

    const maxX = config.GAME_CONFIG.GRID_COLS;
    const maxY = config.GAME_CONFIG.GRID_ROWS;

    this.hurricanes.forEach((hurricane) => {
      hurricane.x += hurricane.vx * deltaSec;
      hurricane.y += hurricane.vy * deltaSec;
      hurricane.rotationRad +=
        config.GAME_CONFIG.HURRICANE_VISUAL_ROTATION_SPEED * deltaSec;

      if (hurricane.x - hurricane.radius < 0) {
        hurricane.x = hurricane.radius;
        hurricane.vx *= -1;
      } else if (hurricane.x + hurricane.radius > maxX) {
        hurricane.x = maxX - hurricane.radius;
        hurricane.vx *= -1;
      }

      if (hurricane.y - hurricane.radius < 0) {
        hurricane.y = hurricane.radius;
        hurricane.vy *= -1;
      } else if (hurricane.y + hurricane.radius > maxY) {
        hurricane.y = maxY - hurricane.radius;
        hurricane.vy *= -1;
      }
    });
  }

  /** 同期配信用のハリケーン状態配列を返す */
  public getUpdatePayload(): HurricaneStatePayload[] {
    return this.hurricanes.map((hurricane) => ({
      id: hurricane.id,
      x: hurricane.x,
      y: hurricane.y,
      radius: hurricane.radius,
      rotationRad: hurricane.rotationRad,
    }));
  }

  /** クールダウン付きで被弾プレイヤーID配列を返す */
  public collectHitPlayerIds(
    players: Map<string, Player>,
    nowMs: number,
  ): string[] {
    if (this.hurricanes.length === 0) {
      return [];
    }

    const hitPlayerIds: string[] = [];
    const hitCooldownMs = config.GAME_CONFIG.HURRICANE_HIT_COOLDOWN_MS;

    players.forEach((player) => {
      const lastHitAtMs = this.lastHitAtMsByPlayerId.get(player.id);
      if (lastHitAtMs !== undefined && nowMs - lastHitAtMs < hitCooldownMs) {
        return;
      }

      const isHit = this.hurricanes.some((hurricane) => {
        const result = checkBombHit({
          bomb: {
            x: hurricane.x,
            y: hurricane.y,
            radius: hurricane.radius,
            teamId: -1,
          },
          player: {
            x: player.x,
            y: player.y,
            radius: config.GAME_CONFIG.PLAYER_RADIUS,
            teamId: player.teamId,
          },
        });

        return result.isHit;
      });

      if (!isHit) {
        return;
      }

      this.lastHitAtMsByPlayerId.set(player.id, nowMs);
      hitPlayerIds.push(player.id);
    });

    return hitPlayerIds;
  }

  /** 状態を初期化する */
  public clear(): void {
    this.hasSpawned = false;
    this.hurricanes = [];
    this.lastHitAtMsByPlayerId.clear();
  }

  /** ハリケーン初期状態を生成する */
  private createHurricane(index: number): HurricaneState {
    const radius = config.GAME_CONFIG.HURRICANE_DIAMETER_GRID / 2;
    const x = this.randomInRange(radius, config.GAME_CONFIG.GRID_COLS - radius);
    const y = this.randomInRange(radius, config.GAME_CONFIG.GRID_ROWS - radius);
    const directionRad = this.randomInRange(0, Math.PI * 2);
    const speed = config.GAME_CONFIG.HURRICANE_MOVE_SPEED;

    return {
      id: `hurricane-${index + 1}`,
      x,
      y,
      vx: Math.cos(directionRad) * speed,
      vy: Math.sin(directionRad) * speed,
      radius,
      rotationRad: directionRad,
    };
  }

  private randomInRange(min: number, max: number): number {
    return min + Math.random() * Math.max(0, max - min);
  }
}