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-pattern fire* helper.
  • tickSustained(host, instance, def, dt, world) — exported per-frame driver. Decrements telegraph, routes to the deferred execute* body when telegraph elapses, and runs tick* for sustained patterns. Returns true while busy so the caller suppresses cooldown decrement.
  • TelegraphState shape (phase: 'idle' | 'telegraph' | 'sustain', telegraph, sustain, cadence, emitTimer, angle, targetX, targetY, lastDir) and the getState(instance) lazy initializer.
  • Param coercion helpers: num, numOpt, str, strOpt, strArr — crash on wrong types, return fallbacks only when key is undefined.
  • SpawnedBullet interface and spawnBullet(world, x, y, dirX, dirY, speed, damage, radius, color, maxDist) — pushes onto world.enemyBullets with flags _cullOutsideArena: true and _abilityBullet: true.
  • pickArenaPositions(arena, mode, count) — resolves 'cardinal' / 'ring' / 'random' positions from the arena.
  • Per-pattern fire* / execute* / tick* helpers for each of the nine PatternId values.
  • Lazy module-level VfxLayerKit singleton accessed through vfx().

READS FROM

  • ../core/stategame (for game.bossArena and damage pipeline context) and ship (target position, damage target).
  • ../core/typesAbilityInstance, BossArena, EnemyEntity, WorldState.
  • ../vfx/boss-layerscreateVfxLayerKit() / VfxLayerKit for telegraphBloom, telegraphLine, telegraphCone, telegraphCircle, additiveSparkBurst, shockwaveBand, groundImpactCircle, beamCharge, spiralSparkBurst, screenTintPulse.
  • ../combat/damagedamagePlayer(ship, dmg, game) for direct damage from telegraphed_circles, beam_sweep, and death_beam.
  • ../enemies/spawnerGameMaster.spawnEnemy(world, ship, enemyTypeId, x, y) for spawn_telegraph.
  • ./indexAbilityDef, AbilityParamValue types.
  • BossArena methods consumed: diagonal(), radius(), cardinalPoints(t), ringPoints(count, t), randomPoint(), fields cx / cy.
  • Host fields: host.x, host.y, host.angle, host.radius.

PUSHES TO

  • world.enemyBullets — appends SpawnedBullet objects (radial bursts, aimed volleys, cone slam fan, spiral emits, wall sweep rows). All carry _cullOutsideArena: true and _abilityBullet: true for the bridge bullet update loop.
  • instance.state — mutates the shared TelegraphState bag, plus pattern-specific scratch (_circles for telegraphed_circles, _spawns for spawn_telegraph).
  • VFX kit — paints telegraphs and impacts (no return value).
  • damagePlayer — applies direct damage for area / beam patterns.
  • GameMaster.spawnEnemy — instantiates enemies for spawn_telegraph; sets enemy.affixes from the ability’s affixIds.

DOES NOT

  • Does not mutate player bullets, pickups, particles outside the VFX kit, or any non-enemyBullets field on world.
  • Does not consume cooldowns, charges, or any other AbilityInstance field besides state.
  • Does not decide when to fire — the caller chooses; firePattern only 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() returns null and 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 / str throw with the key name and observed typeof.
  • Does not apply damage through bullets for telegraphed_circles, beam_sweep, or death_beam — those use damagePlayer directly for deterministic, tick-order-independent resolution.

Signals

  • Exports: firePattern, tickSustained (the only public surface).
  • tickSustained return value: true while state.phase !== 'idle' (still telegraphing or sustaining); false once the pattern returns to idle. Callers in index.ts use this to skip cooldown decrement.
  • Param errors: thrown Error with message pattern param "<key>" must be <type>, got <observed> when a required param has the wrong runtime type.
  • Exhaustiveness: switch blocks on def.pattern use const exhaustive: never = def.pattern and throw on unknown values, so adding a new PatternId produces a type error at compile time and a thrown Error at runtime if dispatch is missed.
  • VFX singleton: _vfxKit is constructed on first vfx() call to dodge bootstrap ordering with the VFX system.

Entry points

  • firePattern(host, instance, def, world) — call once per intended fire. The world argument is currently unused at fire time (kept for symmetry with tickSustained).
  • tickSustained(host, instance, def, dt, world) — call every frame for every active AbilityInstance. 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 fires count bullets evenly around 360° from host.(x,y) at bulletSpeed, 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 fires count bullets in a spread-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-width halfWidth degrees and length length along host.angle. Execute fires bulletCount (default 8) chunky bullets (radius 8) fanned across the cone, speed scaled so bullets reach the tip in ~0.6 s. Adds a shockwaveBand on impact. Default color #ff3300. Single-shot.
  • telegraphed_circles — telegraph (default 1500 ms) paints circleCount ground circles at positions chosen by arenaPositions mode ('cardinal' / 'ring' / 'random', default 'random'). Stashes the chosen points in instance.state._circles. Execute checks ship-in-circle for each stored point and calls damagePlayer(ship, circleDamage, game) directly. Default color #ff8800. Single-shot. Host position is unused (signature kept for symmetry).
  • beam_sweep — telegraph (default 1000 ms) plays beamCharge on the host plus a thin start-angle line. Sustains for sweepDurationMs rotating the beam at arcDegrees / sweepDuration rad/s. Each tick performs a segment-circle intersection (beam width 12) against the ship and applies damagePerSec * dt via damagePlayer. Beam length defaults to arena.radius(). Default color #ff0066. Sustained.
  • spiral_vortex — no telegraph; enters sustain immediately on firePattern with a spiralSparkBurst flourish. Each tick emits bulletPerEmit bullets every emitIntervalMs arranged evenly around an accumulating angle that rotates at rotationSpeedDegPerSec. Bullet damage hardcoded to 1, radius 6, color default #cc66ff. Sustained for sustainMs. Cadence handled by accumulator loop (while emitTimer >= interval).
  • wall_sweep — telegraph (default 2000 ms) draws the starting wall edge across the arena. direction is 'horizontal' (default) or 'vertical'. state.lastDir flips each fire (1-11 …) to alternate which side the wall enters from. Execute walks the perpendicular axis at spacing = bulletRad * 2 (bulletRad = 8), skipping bullets that fall inside any of gapCount randomly placed gaps (each gapWidth wide), spawning the rest moving toward the opposite edge at wallSpeed doing bulletDamage. Default color #ff0044. Single-shot (telegraph then execute). Host is unused — geometry comes from arena.
  • spawn_telegraph — telegraph (default 1500 ms) paints circleCount green (#88ff88) circles at positions chosen by positions mode (default 'cardinal'). Stashes points in instance.state._spawns. Execute calls GameMaster.spawnEnemy(world, ship, enemyTypeId, x, y) at each point and applies optional affixIds to the spawned enemy. Replaces a legacy world.defer queue 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 a screenTintPulse. Sustains for sustainMs. Each tick re-tests ship-on-line against lineWidth / 2 and, on hit, calls damagePlayer(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 their execute* body and set state.phase = 'idle'.
  • beam_sweep and death_beam set state.phase = 'sustain' and the next frame begins per-tick work.
  • spiral_vortex should 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.