PURPOSE

Renders a small pulsing red dot with a fading trail that traces the convex-hull outline of each living enemy’s polygon shape, plus a colored stroke halo around any enemy carrying a known affix. Drawn after the sticker blit so the dot and halo appear on top of the enemy sprite. The orbit is a generic elite/visibility VFX; the affix halo is an elite-tier signal keyed off AFFIX_VFX_PALETTE and is a no-op for unaffixed enemies.

OWNS

  • ORBIT_SPEED, OUTLINE_EXPAND, DOT_RADIUS, GLOW_RADIUS, DOT_ALPHA, PULSE_HZ, PULSE_DEPTH, TRAIL_COUNT, TRAIL_SPAN_FRAC tuning constants.
  • OutlineCache interface and the module-private _outlineCache Map<string, OutlineCache> keyed by shapeKey.
  • OrbitState interface and the module-private _states Map<number, OrbitState> keyed by enemy id.
  • _cleanupCounter for periodic stale-state pruning.
  • Convex-hull construction (_convexHull via Graham scan), outline expansion from centroid, cumulative edge-length parameterization (_buildOutline), and arc-length sampling (_sampleOutline) using a binary search over cumLen.
  • Per-enemy orbit fraction advancement at a constant speed and the per-frame draw loop for trail segments, core dot, soft radial-gradient glow, and the optional affix halo arc.

READS FROM

  • W, H, camera, game from ../core for screen bounds, camera zoom, and game.time.
  • Camera.toS from ../rendering/camera for world-to-screen conversion.
  • Shapes.get and ShapeDef from ../rendering/shapes to retrieve polygon vertices the first time a shapeKey is seen.
  • getAffixVfxColor from ../affixes/palette to look up the halo color for each affix defId on an enemy.
  • Per-enemy fields: alive, _spawnT, id, shapeKey (falling back to typeId), x, y, angle, radius, and affixes[].defId.

PUSHES TO

  • CanvasRenderingContext2D passed into drawEnemyOrbits: mutates globalCompositeOperation (lighter for trail and glow, source-over reset per enemy), globalAlpha, fillStyle, strokeStyle, lineWidth, and issues beginPath / arc / fill / stroke calls. Wrapped in ctx.save() / ctx.restore().
  • Mutates the internal _states map (insert on first sight of an enemy, advance frac, delete on cleanup) and the internal _outlineCache map (insert on first sight of a shapeKey).

DOES NOT

  • Does not apply damage, status effects, or any gameplay effect — VFX only.
  • Does not scale orbit speed with enemy distance, attack state, HP, or affix count; speed is a single constant for all enemies.
  • Does not draw for enemies with alive === false or _spawnT > 0.
  • Does not draw the affix halo for enemies whose affix list yields no entry in AFFIX_VFX_PALETTE — the lookup returns null and the halo block is skipped.
  • Does not allocate per-frame outline data; convex hulls are built once per shapeKey and cached for the rest of the run.
  • Does not handle world-to-screen culling for the trail or core via the whole enemy bounding box — each sampled point is individually rejected when more than 20 screen-px outside the viewport; the halo uses a 40-px margin on the enemy centroid.
  • Does not invalidate the outline cache; once a shape is hulled it stays cached until the process ends.

Signals

  • Constant slow orbit per enemy (ORBIT_SPEED = 0.15 revolutions per second along the full perimeter), independent of enemy type, distance, or behavior.
  • Per-enemy phase offset: each enemy’s initial frac is Math.random() and the pulse uses e.id * 1.7 as a phase term so neighboring enemies do not pulse in lockstep.
  • Trail of TRAIL_COUNT segments trailing the dot over TRAIL_SPAN_FRAC of the perimeter, each at progressively lower alpha and radius.
  • Bright red core (#ff5533) plus a radial-gradient glow halo (#ff6633#ff331180 → transparent) at GLOW_RADIUS screen-px scaled by camera.zoom.
  • Subtle alpha pulse: DOT_ALPHA = 0.8 modulated at PULSE_HZ = 1.8 Hz with depth PULSE_DEPTH = 0.15.
  • Affix halo: stroked ring at the enemy centroid with radius enemy.radius * OUTLINE_EXPAND * camera.zoom * 1.08, color from getAffixVfxColor on the first affix that returns non-null, alpha pulsing on the same phase as the orbit dot at 0.85 * (0.55 + 0.45 * pulse01), line width max(1.5, 2.2 * camera.zoom).

Entry points

  • updateEnemyOrbits(enemies, dt) — call once per frame to advance each living enemy’s frac by ORBIT_SPEED * dt and lazily allocate per-enemy state. Every ~120 calls it sweeps _states and deletes entries whose enemy id is no longer in the supplied list.
  • drawEnemyOrbits(ctx, enemies) — call once per frame after the enemy sticker pass to render trail, core, glow, and affix halo for each living enemy. Lazily builds and caches the convex-hull outline per shapeKey on first use.
  • resetEnemyOrbits() — clears _states and resets _cleanupCounter. Intended for mission start/end transitions.

Pattern notes

  • Outline cache is keyed by shapeKey (or typeId fallback), not by enemy id, so all instances of the same shape share one convex hull. The hull is computed via Graham scan on the union of all polygon vertices in the ShapeDef, then expanded outward from the centroid by OUTLINE_EXPAND = 1.28 to clear the sticker layer’s ~7.5 px black stroke.
  • Arc-length parameterization (cumLen[] storing cumulative edge length, with a binary search in _sampleOutline) gives constant-speed motion along irregular polygons, instead of speeding up on long edges or stalling on short ones.
  • Trail segments and the soft glow use globalCompositeOperation = 'lighter' (additive) so they blend into the bloom pipeline; the composite is reset to source-over at the end of each enemy iteration.
  • Sampled positions are transformed from unit space into world space by rotating with cos(angle) / sin(angle) and scaling by enemy.radius, then converted through Camera.toS; off-screen samples are skipped per-point rather than per-enemy so partial cull works for large rotated polygons.
  • Cleanup runs every 120 update calls (~2 s at 60 Hz). Between sweeps, _states can hold entries for dead enemies until the next pass.
  • The affix halo lookup short-circuits: it walks e.affixes and uses the first affix whose defId is keyed in AFFIX_VFX_PALETTE. Affixes outside the palette never contribute color — they pass through silently, leaving only the generic orbit dot.
  • _buildOutline allocates verts, cumLen, and intermediate arrays — kept out of the hot path by the per-shapeKey cache, which means startup cost on the first enemy of each shape but zero allocations on subsequent frames.
  • Hard dependency on Shapes.get(shapeKey) returning a non-null ShapeDef; enemies whose shapeKey (or typeId fallback) is missing from the shape registry are silently skipped rather than crashing.