首页 / 模块目录 / 玩法状态机 / ProjectileWeaponSystem

ProjectileWeaponSystem

纯逻辑

管理枪/导弹武器选择、弹药、冷却、枪管过热、导弹锁定、开火决策(发射位置/方向/速度)。

类别玩法状态机
依赖档位纯逻辑
相关模块
演示场arena-combat · 将随该演示场展示(即将上线)
只取这个模块
modules/gameplay/combat/ProjectileWeaponSystem.js

连同其内部依赖一并复制,保留相对目录结构。

源码

import { DEFAULT_CLOCK } from '../../math/TimeUtils.js';
import { toVec3 } from '../../math/Vector3Utils.js';

export const WEAPON_DECISIONS = Object.freeze({
  FIRE_GUN: 'fire-gun',
  FIRE_MISSILE: 'fire-missile',
  BLOCKED: 'blocked',
  EMPTY_WARNING: 'empty-warning',
});

export const WEAPON_TYPES = Object.freeze({
  GUN: 'gun',
  MISSILE: 'missile',
});

export const WEAPON_AIM_MODES = Object.freeze({
  BORESIGHT: 'boresight',
  CROSSHAIR: 'crosshair',
});

export const MISSILE_LOCK_STATUS = Object.freeze({
  NONE: 'NONE',
  LOCKING: 'LOCKING',
  LOCKED: 'LOCKED',
});

function calculateDist(sourcePosition, targetPosition) {
  return Math.hypot(
    targetPosition.x - sourcePosition.x,
    targetPosition.y - sourcePosition.y,
    targetPosition.z - sourcePosition.z
  );
}

function calculateDotProduct(sourcePosition, targetPosition, aimDirection) {
  const dX = targetPosition.x - sourcePosition.x;
  const dY = targetPosition.y - sourcePosition.y;
  const dZ = targetPosition.z - sourcePosition.z;
  const len = Math.hypot(dX, dY, dZ);
  if (len <= 1e-6) return 0;

  return aimDirection.x * (dX / len) + aimDirection.y * (dY / len) + aimDirection.z * (dZ / len);
}

export class ProjectileWeaponSystem {
  constructor({
    lockRequiredSeconds = 1.0,
    gunHeatPerShot = 0.02,
    gunCoolRatePerSecond = 0.2,
    gunOverheatThreshold = 1.0,
    gunRecoveredThreshold = 0.3,
    emptyWarningCooldownSeconds = 2.0,
    aimMode = WEAPON_AIM_MODES.CROSSHAIR,
    targetAimDotMin = 0.94,
    targetMaxDistance = 10000,
    clock = DEFAULT_CLOCK,
  }) {
    this.clock = clock;
    this.aimMode = aimMode;
    this.cfg = {
      lockRequiredSeconds,
      gunHeatPerShot,
      gunCoolRatePerSecond,
      gunOverheatThreshold,
      gunRecoveredThreshold,
      emptyWarningCooldownSeconds,
      targetAimDotMin,
      targetMaxDistance
    };

    this.weapons = new Map();
    this.weapons.set(WEAPON_TYPES.GUN, {id: WEAPON_TYPES.GUN, lastFireTime: -Infinity, ammo: Infinity, maxAmmo: Infinity, fireRate: 0.05});
    this.weapons.set(WEAPON_TYPES.MISSILE, {id: WEAPON_TYPES.MISSILE, lastFireTime: -Infinity, ammo: 50, maxAmmo: 50, fireRate: 1.0});
    this.weaponIds = [WEAPON_TYPES.GUN, WEAPON_TYPES.MISSILE];

    this.selectedWeaponId = WEAPON_TYPES.GUN;
    this.target = null;
    this.isGunOverheated = false;
    this.gunHeat = 0;
    this.lockTime = 0;
    this.lockStatus = MISSILE_LOCK_STATUS.NONE;
    this.lockingTarget = null;
    this.emptyWarningTimers = {
      [WEAPON_TYPES.GUN]: 0,
      [WEAPON_TYPES.MISSILE]: 0,
    };
    this.lastEmptyWarningAtSeconds = 0;
  }

  updateWeaponConfig(weaponId, {ammo, maxAmmo, fireRate, speed, launchOffset}) {
    const weapon = this.weapons.get(weaponId);
    weapon.ammo = ammo;
    weapon.maxAmmo = maxAmmo;
    weapon.fireRate = fireRate;
    weapon.speed = speed;
    weapon.launchOffset = launchOffset;
  }

  resetAmmo() {
    for (const weaponId of this.weaponIds) {
      const weapon = this.weapons.get(weaponId);
      weapon.ammo = weapon.maxAmmo;
      weapon.lastFireTime = -Infinity;
    }
    this.selectedWeaponId = WEAPON_TYPES.GUN;
    this.target = null;
    this.isGunOverheated = false;
    this.gunHeat = 0;
    this.lockTime = 0;
    this.lockStatus = MISSILE_LOCK_STATUS.NONE;
    this.lockingTarget = null;
    this.emptyWarningTimers = {
      [WEAPON_TYPES.GUN]: 0,
      [WEAPON_TYPES.MISSILE]: 0,
    };
    this.lastEmptyWarningAtSeconds = 0;
  }

  getCurrentWeapon() {
    return this.weapons.get(this.selectedWeaponId);
  }

  getLaunchPosition(shooterPosition, shooterBodyFrame, weaponId = null) {
    const weapon = weaponId ? this.weapons.get(weaponId) : this.getCurrentWeapon();
    if (!weapon) return shooterPosition;
    const shooterOrigin = toVec3(shooterPosition);
    const offset = weapon.launchOffset ?? {};
    return shooterBodyFrame.right.clone()
      .multiplyScalar(offset.right ?? 0)
      .addScaledVector(shooterBodyFrame.up, offset.up ?? 0)
      .addScaledVector(shooterBodyFrame.forward, offset.forward ?? 0)
      .add(shooterOrigin);
  }

  toggleWeapon() {
    const currentIndex = Math.max(0, this.weaponIds.indexOf(this.selectedWeaponId));
    const nextIndex = (currentIndex + 1) % this.weaponIds.length;
    this.selectedWeaponId = this.weaponIds[nextIndex];
  }

  selectWeapon(weaponId) {
    if (!this.weapons.has(weaponId)) return null;
    this.selectedWeaponId = weaponId;
  }

  // In crosshair mode, aimPosition is the world-space point the launched shot should travel toward from its launch position, it can come from AimResolver's getAimFromCamera(...).hitPosition or AimResolver's getAimFromAimRay(...).hitPosition.
  requestFire({
    shooterPosition,
    shooterBodyFrame,
    aimPosition = null,
    weaponId = null
  }) {
    const weapon = weaponId ? this.weapons.get(weaponId) : this.getCurrentWeapon();
    if (!weapon) return null;

    if (this.aimMode === WEAPON_AIM_MODES.CROSSHAIR && !aimPosition) {
      throw new TypeError('ProjectileWeaponSystem: crosshair fire requires aimPosition');
    }

    const now = this.clock.nowSeconds();
    if (weapon.ammo <= 0) return this._emptyWarning(weapon.id, now);
    if (weapon.id === WEAPON_TYPES.GUN && this.isGunOverheated) {
      return { type: WEAPON_DECISIONS.BLOCKED, message: 'Weapon overheated', weapon };
    }
    if (now - weapon.lastFireTime < weapon.fireRate) {
      return { type: WEAPON_DECISIONS.BLOCKED, message: 'Weapon cooldown', weapon };
    }
    if (weapon.id === WEAPON_TYPES.MISSILE && this.lockStatus !== MISSILE_LOCK_STATUS.LOCKED) {
      return { type: WEAPON_DECISIONS.BLOCKED, message: 'Missile needs lock', weapon };
    }

    const motionState = this._computeLaunchMotionState(
      weapon,
      shooterPosition,
      shooterBodyFrame,
      aimPosition
    );

    weapon.lastFireTime = now;
    if (weapon.ammo !== Infinity) weapon.ammo -= 1;

    if (weapon.id === WEAPON_TYPES.GUN) {
      this.gunHeat += this.cfg.gunHeatPerShot;
      const overheated = this.gunHeat >= this.cfg.gunOverheatThreshold;
      if (overheated) this.isGunOverheated = true;
      return {
        type: WEAPON_DECISIONS.FIRE_GUN,
        weapon,
        overheated,
        ...motionState
      };
    }

    if (weapon.id === WEAPON_TYPES.MISSILE) {
      return {
        type: WEAPON_DECISIONS.FIRE_MISSILE,
        weapon,
        target: this.target,
        ...motionState
      };
    }

    return { type: WEAPON_DECISIONS.BLOCKED, message: 'Unsupported weapon', weapon };
  }

  // In crosshair mode, aimDirection is the world-space direction the shooter is currently aiming, it can come from AimResolver's getAimDirection(camera, crosshairNdc).
  step({
    shooterPosition,
    shooterBodyFrame,
    aimDirection = null,
    targets = [],
    deltaSeconds = 1 / 60
  }) {
    const currentWeapon = this.getCurrentWeapon();

    if (currentWeapon.id === WEAPON_TYPES.MISSILE) {
      this._stepMissileLock({
        shooterPosition,
        shooterBodyFrame,
        aimDirection,
        targets,
        deltaSeconds
      });
    } else {
      this.lockingTarget = null;
      this.lockTime = 0;
      this.lockStatus = MISSILE_LOCK_STATUS.NONE;
      this.target = null;
    }

    this._stepGunHeat(deltaSeconds);
    this._stepEmptyWarningCooldowns(deltaSeconds);
  }

  findPotentialTarget({
    shooterPosition,
    shooterBodyFrame,
    aimDirection = null,
    targets = []
  }) {
    const position = toVec3(shooterPosition);
    let resolvedAimDirection;
    if (this.aimMode === WEAPON_AIM_MODES.BORESIGHT) {
      resolvedAimDirection = shooterBodyFrame.forward.clone();
    } else if (this.aimMode === WEAPON_AIM_MODES.CROSSHAIR) {
      resolvedAimDirection = toVec3(aimDirection);
    }
    resolvedAimDirection.normalize();

    let bestTarget = null;
    let maxDot = this.cfg.targetAimDotMin;

    for (const target of targets) {
      if (target.destroyed) continue;

      const dot = calculateDotProduct(position, target.position, resolvedAimDirection);
      if (dot <= maxDot) continue;

      const dist = calculateDist(position, target.position);
      if (dist >= this.cfg.targetMaxDistance) continue;

      bestTarget = target;
      maxDot = dot;
    }

    return bestTarget;
  }

  _stepMissileLock({
    shooterPosition,
    shooterBodyFrame,
    aimDirection,
    targets,
    deltaSeconds
  }) {
    const potentialTarget = this.findPotentialTarget({
      shooterPosition,
      shooterBodyFrame,
      aimDirection,
      targets
    });

    if (!potentialTarget) {
      this.lockingTarget = null;
      this.lockTime = 0;
      this.lockStatus = MISSILE_LOCK_STATUS.NONE;
      this.target = null;
      return;
    }

    if (this.lockingTarget !== potentialTarget) {
      this.lockingTarget = potentialTarget;
      this.lockTime = 0;
      this.lockStatus = MISSILE_LOCK_STATUS.LOCKING;
      this.target = null;
      return;
    }

    this.lockTime += deltaSeconds;
    if (this.lockTime >= this.cfg.lockRequiredSeconds) {
      this.lockStatus = MISSILE_LOCK_STATUS.LOCKED;
      this.target = potentialTarget;
      return;
    }

    this.lockStatus = MISSILE_LOCK_STATUS.LOCKING;
  }

  _computeLaunchMotionState(
    weapon,
    shooterPosition,
    shooterBodyFrame,
    aimPosition = null
  ) {
    const position = this.getLaunchPosition(shooterPosition, shooterBodyFrame, weapon.id);
    let direction;
    if (this.aimMode === WEAPON_AIM_MODES.BORESIGHT) {
      direction = shooterBodyFrame.forward.clone();
    } else if (this.aimMode === WEAPON_AIM_MODES.CROSSHAIR) {
      direction = toVec3(aimPosition).sub(position);
    }
    if (direction.lengthSq() <= 1e-12) {
      throw new TypeError('ProjectileWeaponSystem: fire direction must be non-zero');
    }
    direction.normalize();

    return {
      position: position,
      direction,
      speed: weapon.speed
    };
  }

  _stepGunHeat(deltaSeconds) {
    if (this.gunHeat <= 0) return;

    this.gunHeat -= deltaSeconds * this.cfg.gunCoolRatePerSecond;
    if (this.gunHeat <= 0) {
      this.gunHeat = 0;
      this.isGunOverheated = false;
    }
    if (this.isGunOverheated && this.gunHeat < this.cfg.gunRecoveredThreshold) {
      this.isGunOverheated = false;
    }
  }

  _stepEmptyWarningCooldowns(deltaSeconds) {
    for (const key in this.emptyWarningTimers) {
      if (this.emptyWarningTimers[key] <= 0) continue;
      this.emptyWarningTimers[key] -= deltaSeconds;
      if (this.emptyWarningTimers[key] < 0) this.emptyWarningTimers[key] = 0;
    }
  }

  _emptyWarning(weaponId, now) {
    if (now - this.lastEmptyWarningAtSeconds <= this.cfg.emptyWarningCooldownSeconds) {
      return { type: WEAPON_DECISIONS.BLOCKED, message: 'Weapon empty', weaponId };
    }

    this.emptyWarningTimers[weaponId] = 1.0;
    this.lastEmptyWarningAtSeconds = now;
    return { type: WEAPON_DECISIONS.EMPTY_WARNING, weaponId };
  }
}