Weapon target acquisition (acquireRange + targetMode)

Every weapon picks its target at fire time by scanning enemies within its acquireRange and scoring them by its targetMode. No target in range, no fire — but cooldown still ticks so the weapon is ready the instant something enters range. Manual mode bypasses the scan and aims by player input.

The two fields

Both fields live on WeaponCoreSpec (data/weapons/_types.ts) and are required for every weapon:

  • acquireRange: WeaponStat — radius (world px) of the auto-aim search circle, centered on the ship. Stored as { base, scaling } and resolved to a per-level value via getWeaponStatAtLevel. Base values cluster in the 150–400 px range for normal weapons (shotgun 151, flame 165, rifle 183, cannon 204, mortar 296, magnetar 350, railgun 380); legendaries reach 600–2000 px (e.g. Star Halo 1300, Hellrain 2000). There is no hard-coded “600 default” — every weapon declares its own.
  • targetMode: TargetMode — which enemy in range to pick. Union of 'closest' | 'furthest' | 'flanking' | 'low_hp' in the type def, plus 'high_hp' accepted by the runtime scorer.

How a target gets picked

The canonical scorer is WeaponManager.getAutoAimAngle(ship, world, range, …, targetMode) in engine/weapons/weapons.ts. On each fire attempt:

  1. Spatial-grid query. enemyGrid.query(ship.x, ship.y, range + 30) returns enemy candidates near the ship. The grid is rebuilt every frame by CollisionResolver.rebuildEnemyGrid — a 30 px slop is added to the query so enemies sitting on the edge of a cell aren’t missed.
  2. Grid-miss fallback. If the grid returns no candidates, the scorer falls back to the full world.enemies list. This handles boss fights where the grid can be stale (a weapon fires before the next rebuild) and the early frames after spawn.
  3. Filter. Skip enemies that are !alive, _frozenForLag, _dying, or still in warp-in (_spawnT > 0.15).
  4. Edge-distance gate. Compute edgeDist = max(0, centerDist − enemy.radius) and drop anything where edgeDist > range. Measuring to the closest point on the enemy’s circle (not its center) means larger enemies effectively count as closer — chunky targets get picked up sooner.
  5. Score by targetMode. Lower score wins (the scorer tracks bestScore = Infinity and replaces on score < bestScore):
    • closestscore = edgeDist. Default.
    • furthestscore = -edgeDist. Picks the farthest enemy still in range (mortar, railgun — punches the backline).
    • flankingscore = -|sin(angleToEnemy - ship.angle)| * 1000 + edgeDist * 0.001. Prefers enemies perpendicular to ship facing; among equally-sideways enemies, the closer one wins. Used by cannon and revolver for side coverage.
    • high_hpscore = -hp. Tankiest target.
    • low_hpscore = hp. Wounded target (missile — finish them off).
  6. Aim. Return atan2(target.y - ship.y, target.x - ship.x) to the caller, which feeds it into bullet/beam spawn.

What happens with no target

If the scorer returns undefined, the fire() path returns before warmup begins (see WeaponManager.fire in weapons.ts). The cooldown timer (weapon.fireTimer) is not reset here — updateCooldowns() decrements it every frame regardless, so the weapon is “loaded” the moment an enemy enters range and will warmup + fire on the next eligible frame.

One exception: weapons with behavior: 'orbit_ring' (the Star Halo legendary) fire continuously on timer without needing a target, because the cast is centered on the player.

Effective range vs raw acquireRange

getWeaponEffectiveRange(weapon) (used by the AI to decide when to start firing as enemies approach, not for the fire-time scorer itself) returns a slightly inflated value:

  • Starts from getWeaponStatAtLevel(def.acquireRange, lvl).
  • If areaMode is set, expands to whichever is larger of: orbitRadius, acquireRange + 0.7 * blastRadius, or acquireRange + 0.3 * beamWidth.
  • Multiplies by 1.05 (a 5% generosity buffer — weapons should err on the side of firing).
  • Clamped to a minimum of 50 px.

The fire-time scorer itself uses the raw acquireRange * rangeMult value, not this inflated number.

Multi-target variants

For weapons that fire more than one projectile or beam per volley, two specialized scorers reuse the same scoring rules but pick N distinct targets:

  • _getMultiTargetAngles(ship, world, range, count, …, targetMode) — scores all enemies in range with the same switch statement as getAutoAimAngle, sorts ascending by score, takes up to count unique targets, and returns one aim angle per target. Falls back to duplicating the primary angle when fewer than count enemies exist. Used by non-spread weapons with projectileCount > 1 so extra shots spread across separate targets instead of stacking on one.
  • _getHomingTargets(ship, world, range, count) — same grid query and filter, but always sorts by squared center-distance and assigns specific enemy refs to each homing missile so they track separate targets. Cycles through the available list when count exceeds live enemy count.
  • _fireDirectHitscan(…) — burst rifle’s pure direct-damage path. Same scoring as _getMultiTargetAngles but applies damageEnemy directly per shot; leftover shots cycle back to the head of the sorted list so every shot lands while anything is in range.

Manual mode

When the player holds a manual-fire input, bridge.ts calls setManualFireFlag(true) before invoking WeaponManager.fire(). The caller passes its own aimAngle (joystick direction or pointer angle), so the getAutoAimAngle path is skipped entirely — no grid query, no target scoring, no acquireRange check. The flag also tags the bullet with _manualFire so the homing behavior knows to ramp up homing strength over the first half of the bullet’s lifetime, compensating for the player’s potentially imperfect aim.

Why edge distance instead of center distance

Boss enemies and mini-bosses have radii of 60–200 px. Measuring to the enemy’s center would mean a railgun with acquireRange: 380 can’t lock a 150 px-radius mid-boss whose hull is already overlapping the player. Edge distance fixes this — the ship can target anything whose surface is within range, which is the intuitive expectation.

Cross-references

  • Target modes — full per-mode tuning rationale and which weapons use which mode: see gameplay/concepts/target-modes.
  • Warmup — what happens after a target is acquired but before the bullet spawns: see gameplay/concepts/weapon-warmup.
  • Spatial grid — how enemyGrid is built and queried: see engine/collision/spatial-grid.
  • Manual mode — joystick/pointer input pipeline: see gameplay/concepts/manual-fire-mode.