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_FRACtuning constants.OutlineCacheinterface and the module-private_outlineCacheMap<string, OutlineCache>keyed byshapeKey.OrbitStateinterface and the module-private_statesMap<number, OrbitState>keyed by enemyid._cleanupCounterfor periodic stale-state pruning.- Convex-hull construction (
_convexHullvia Graham scan), outline expansion from centroid, cumulative edge-length parameterization (_buildOutline), and arc-length sampling (_sampleOutline) using a binary search overcumLen. - 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,gamefrom../corefor screen bounds, camera zoom, andgame.time.Camera.toSfrom../rendering/camerafor world-to-screen conversion.Shapes.getandShapeDeffrom../rendering/shapesto retrieve polygon vertices the first time ashapeKeyis seen.getAffixVfxColorfrom../affixes/paletteto look up the halo color for each affixdefIdon an enemy.- Per-enemy fields:
alive,_spawnT,id,shapeKey(falling back totypeId),x,y,angle,radius, andaffixes[].defId.
PUSHES TO
CanvasRenderingContext2Dpassed intodrawEnemyOrbits: mutatesglobalCompositeOperation(lighterfor trail and glow,source-overreset per enemy),globalAlpha,fillStyle,strokeStyle,lineWidth, and issuesbeginPath/arc/fill/strokecalls. Wrapped inctx.save()/ctx.restore().- Mutates the internal
_statesmap (insert on first sight of an enemy, advancefrac, delete on cleanup) and the internal_outlineCachemap (insert on first sight of ashapeKey).
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 === falseor_spawnT > 0. - Does not draw the affix halo for enemies whose affix list yields no entry in
AFFIX_VFX_PALETTE— the lookup returnsnulland the halo block is skipped. - Does not allocate per-frame outline data; convex hulls are built once per
shapeKeyand 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.15revolutions per second along the full perimeter), independent of enemy type, distance, or behavior. - Per-enemy phase offset: each enemy’s initial
fracisMath.random()and the pulse usese.id * 1.7as a phase term so neighboring enemies do not pulse in lockstep. - Trail of
TRAIL_COUNTsegments trailing the dot overTRAIL_SPAN_FRACof the perimeter, each at progressively lower alpha and radius. - Bright red core (
#ff5533) plus a radial-gradient glow halo (#ff6633→#ff331180→ transparent) atGLOW_RADIUSscreen-px scaled bycamera.zoom. - Subtle alpha pulse:
DOT_ALPHA = 0.8modulated atPULSE_HZ = 1.8Hz with depthPULSE_DEPTH = 0.15. - Affix halo: stroked ring at the enemy centroid with radius
enemy.radius * OUTLINE_EXPAND * camera.zoom * 1.08, color fromgetAffixVfxColoron the first affix that returns non-null, alpha pulsing on the same phase as the orbit dot at0.85 * (0.55 + 0.45 * pulse01), line widthmax(1.5, 2.2 * camera.zoom).
Entry points
updateEnemyOrbits(enemies, dt)— call once per frame to advance each living enemy’sfracbyORBIT_SPEED * dtand lazily allocate per-enemy state. Every ~120 calls it sweeps_statesand deletes entries whose enemyidis 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 pershapeKeyon first use.resetEnemyOrbits()— clears_statesand resets_cleanupCounter. Intended for mission start/end transitions.
Pattern notes
- Outline cache is keyed by
shapeKey(ortypeIdfallback), not by enemyid, 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 theShapeDef, then expanded outward from the centroid byOUTLINE_EXPAND = 1.28to 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 tosource-overat 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 byenemy.radius, then converted throughCamera.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,
_statescan hold entries for dead enemies until the next pass. - The affix halo lookup short-circuits: it walks
e.affixesand uses the first affix whosedefIdis keyed inAFFIX_VFX_PALETTE. Affixes outside the palette never contribute color — they pass through silently, leaving only the generic orbit dot. _buildOutlineallocatesverts,cumLen, and intermediate arrays — kept out of the hot path by the per-shapeKeycache, 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-nullShapeDef; enemies whoseshapeKey(ortypeIdfallback) is missing from the shape registry are silently skipped rather than crashing.