patterns.ts
PURPOSE
Implements every ability PatternId as a fire fn. Each pattern reads its host enemy, ability params, and the active BossArena geometry; paints telegraphs and impacts through the VFX kit; and spawns enemy bullets (or applies direct damage) on execute. A per-instance state bag drives telegraph countdowns and sustained per-frame work so single-shot patterns stay stateless between fires while sustained patterns (spiral_vortex, beam_sweep, death_beam) can track time without leaking into AbilityInstance.
Reference spec: docs/superpowers/specs/2026-04-25-bosses-as-enemies-design.md §3.1.
OWNS
firePattern(host, instance, def, world)— exported dispatch entry point that sets up telegraph state for a fresh fire and routes to the per-patternfire*helper.tickSustained(host, instance, def, dt, world)— exported per-frame driver. Decrements telegraph, routes to the deferredexecute*body when telegraph elapses, and runstick*for sustained patterns. Returnstruewhile busy so the caller suppresses cooldown decrement.TelegraphStateshape (phase: 'idle' | 'telegraph' | 'sustain',telegraph,sustain,cadence,emitTimer,angle,targetX,targetY,lastDir) and thegetState(instance)lazy initializer.- Param coercion helpers:
num,numOpt,str,strOpt,strArr— crash on wrong types, return fallbacks only when key isundefined. SpawnedBulletinterface andspawnBullet(world, x, y, dirX, dirY, speed, damage, radius, color, maxDist)— pushes ontoworld.enemyBulletswith flags_cullOutsideArena: trueand_abilityBullet: true.pickArenaPositions(arena, mode, count)— resolves'cardinal'/'ring'/'random'positions from the arena.- Per-pattern
fire*/execute*/tick*helpers for each of the ninePatternIdvalues. - Lazy module-level
VfxLayerKitsingleton accessed throughvfx().
READS FROM
../core/state—game(forgame.bossArenaand damage pipeline context) andship(target position, damage target).../core/types—AbilityInstance,BossArena,EnemyEntity,WorldState.../vfx/boss-layers—createVfxLayerKit()/VfxLayerKitfortelegraphBloom,telegraphLine,telegraphCone,telegraphCircle,additiveSparkBurst,shockwaveBand,groundImpactCircle,beamCharge,spiralSparkBurst,screenTintPulse.../combat/damage—damagePlayer(ship, dmg, game)for direct damage fromtelegraphed_circles,beam_sweep, anddeath_beam.../enemies/spawner—GameMaster.spawnEnemy(world, ship, enemyTypeId, x, y)forspawn_telegraph../index—AbilityDef,AbilityParamValuetypes.BossArenamethods consumed:diagonal(),radius(),cardinalPoints(t),ringPoints(count, t),randomPoint(), fieldscx/cy.- Host fields:
host.x,host.y,host.angle,host.radius.
PUSHES TO
world.enemyBullets— appendsSpawnedBulletobjects (radial bursts, aimed volleys, cone slam fan, spiral emits, wall sweep rows). All carry_cullOutsideArena: trueand_abilityBullet: truefor the bridge bullet update loop.instance.state— mutates the sharedTelegraphStatebag, plus pattern-specific scratch (_circlesfortelegraphed_circles,_spawnsforspawn_telegraph).- VFX kit — paints telegraphs and impacts (no return value).
damagePlayer— applies direct damage for area / beam patterns.GameMaster.spawnEnemy— instantiates enemies forspawn_telegraph; setsenemy.affixesfrom the ability’saffixIds.
DOES NOT
- Does not mutate player bullets, pickups, particles outside the VFX kit, or any non-
enemyBulletsfield onworld. - Does not consume cooldowns, charges, or any other
AbilityInstancefield besidesstate. - Does not decide when to fire — the caller chooses;
firePatternonly sets up the telegraph. - Does not read or write
world.defer(the legacy defer queue was removed; spawning is inline). - Does not crash when no boss arena is active —
getArena()returnsnulland pattern fns no-op so abilities attached to non-boss enemies in test mode are safe. - Does not fall back silently on bad param types —
num/strthrow with the key name and observedtypeof. - Does not apply damage through bullets for
telegraphed_circles,beam_sweep, ordeath_beam— those usedamagePlayerdirectly for deterministic, tick-order-independent resolution.
Signals
- Exports:
firePattern,tickSustained(the only public surface). tickSustainedreturn value:truewhilestate.phase !== 'idle'(still telegraphing or sustaining);falseonce the pattern returns to idle. Callers inindex.tsuse this to skip cooldown decrement.- Param errors: thrown
Errorwith messagepattern param "<key>" must be <type>, got <observed>when a required param has the wrong runtime type. - Exhaustiveness:
switchblocks ondef.patternuseconst exhaustive: never = def.patternand throw on unknown values, so adding a newPatternIdproduces a type error at compile time and a thrownErrorat runtime if dispatch is missed. - VFX singleton:
_vfxKitis constructed on firstvfx()call to dodge bootstrap ordering with the VFX system.
Entry points
firePattern(host, instance, def, world)— call once per intended fire. Theworldargument is currently unused at fire time (kept for symmetry withtickSustained).tickSustained(host, instance, def, dt, world)— call every frame for every activeAbilityInstance. Drives both telegraph countdown and per-frame sustained logic; return value gates cooldown decrement upstream.
Pattern notes
The nine PatternId values handled by this file:
radial_burst— telegraph (default 1000 ms) shows a bloom around the caster; execute firescountbullets evenly around 360° fromhost.(x,y)atbulletSpeed,bulletDamage,bulletRadius,bulletColor(default#ff5500); max travel =arena.diagonal(). Single-shot.aimed_volley— telegraph (default 800 ms) draws a line from caster to the player’s locked position. Execute firescountbullets in aspread-degree fan centered on the locked angle (single shot fires straight). Default color#ffaa00. Single-shot.cone_slam— telegraph (default 1200 ms) paints a cone of half-widthhalfWidthdegrees and lengthlengthalonghost.angle. Execute firesbulletCount(default 8) chunky bullets (radius 8) fanned across the cone, speed scaled so bullets reach the tip in ~0.6 s. Adds ashockwaveBandon impact. Default color#ff3300. Single-shot.telegraphed_circles— telegraph (default 1500 ms) paintscircleCountground circles at positions chosen byarenaPositionsmode ('cardinal'/'ring'/'random', default'random'). Stashes the chosen points ininstance.state._circles. Execute checks ship-in-circle for each stored point and callsdamagePlayer(ship, circleDamage, game)directly. Default color#ff8800. Single-shot. Host position is unused (signature kept for symmetry).beam_sweep— telegraph (default 1000 ms) playsbeamChargeon the host plus a thin start-angle line. Sustains forsweepDurationMsrotating the beam atarcDegrees / sweepDurationrad/s. Each tick performs a segment-circle intersection (beam width 12) against the ship and appliesdamagePerSec * dtviadamagePlayer. Beam length defaults toarena.radius(). Default color#ff0066. Sustained.spiral_vortex— no telegraph; enters sustain immediately onfirePatternwith aspiralSparkBurstflourish. Each tick emitsbulletPerEmitbullets everyemitIntervalMsarranged evenly around an accumulating angle that rotates atrotationSpeedDegPerSec. Bullet damage hardcoded to 1, radius 6, color default#cc66ff. Sustained forsustainMs. Cadence handled by accumulator loop (while emitTimer >= interval).wall_sweep— telegraph (default 2000 ms) draws the starting wall edge across the arena.directionis'horizontal'(default) or'vertical'.state.lastDirflips each fire (1→-1→1…) to alternate which side the wall enters from. Execute walks the perpendicular axis atspacing = bulletRad * 2(bulletRad = 8), skipping bullets that fall inside any ofgapCountrandomly placed gaps (eachgapWidthwide), spawning the rest moving toward the opposite edge atwallSpeeddoingbulletDamage. Default color#ff0044. Single-shot (telegraph then execute). Host is unused — geometry comes from arena.spawn_telegraph— telegraph (default 1500 ms) paintscircleCountgreen (#88ff88) circles at positions chosen bypositionsmode (default'cardinal'). Stashes points ininstance.state._spawns. Execute callsGameMaster.spawnEnemy(world, ship, enemyTypeId, x, y)at each point and applies optionalaffixIdsto the spawned enemy. Replaces a legacyworld.deferqueue path that was silently broken. Single-shot.death_beam— telegraph (default 3000 ms) draws a thick line along the angle to the player (locked at telegraph start), plus ascreenTintPulse. Sustains forsustainMs. Each tick re-tests ship-on-line againstlineWidth / 2and, on hit, callsdamagePlayer(ship, damageInstaKill, game). Beam length =arena.diagonal(). Default color#ff0000. Sustained.
Phase transitions in tickSustained after telegraph elapses:
- Single-shot patterns (
radial_burst,aimed_volley,cone_slam,telegraphed_circles,spawn_telegraph,wall_sweep) run theirexecute*body and setstate.phase = 'idle'. beam_sweepanddeath_beamsetstate.phase = 'sustain'and the next frame begins per-tick work.spiral_vortexshould never reach the telegraph-elapsed branch (it skips telegraph at fire time); the safety branch sets it to idle.
Sustain exit: any sustained pattern with state.sustain <= 0 after its tick* call returns to idle and tickSustained returns false.