/** * The enemy ABILITY LAYER — the faction skills that ride on top of the * weapon patterns (data/combat.json → enemyTypes..abilities; the * rosters are combat.factions → apex / mindweave). * * Like js/combat/Weapons.js: one shared dispatch, pure logic + a `ctx` * host (a CombatSystem — the abilities run in the main game's combat * loop, where the ?combat=apex / ?combat=mindweave duel arena lives). * ctx provides: .scene, .fx, .playerFacing(), .teleportCraft(e, x, y), * and it owns the rosters this module appends to (.tkwaves, .illusions). * * ABILITIES (the `id` values a type's abilities[].id may name): * 'cloaked_ambush' (Apex) the PREDATOR'S LAIR: on first contact the * craft phases to a CLOAKED stand nearby — * often BEHIND the player — invisible, * unhittable, silent (cloakMs); at reveal * it strikes at once (nextFireAt cleared). * With recloakCooldown > 0 it can drop back * into cloak for a recloakMs nap (the * Stalker's signature: strike, vanish, * strike again). * 'flank_strafe' (Apex) the PREDATORY CIRCLE: every `period` s the * craft cuts to a point BEHIND the player * (radius/±spread rad) and holds it for * holdMs — an attack from the bad side. * 'blink_reposition' (Mindweave) the WEAVER'S BLINK (the Wraith's own * blink, inverted): the craft steps to a * fresh point around the player (biasBehind * rolls it toward the rear) and leaves an * ILLUSION decoy standing at the old spot * (decoyMs) — which draws the player's * auto-fire (the cannon's targets pool * includes live illusions — the phantoms * absorb the tracers). * 'illusion_decoy' (Mindweave) the PHANTOM COURT: every `cooldown` s * the craft projects `count` copies of * itself within `radius` (lifeMs) — the * same decoy roster the blink uses. * 'telekinetic_wave' (Mindweave) the PSYCHIC TIDE: every `cooldown` s a * wave blooms from the craft (travelMs to * arrive); at arrival, EVERY hostile inside * `radius` — the player OR a rival faction's * craft (the caster's own wing stands; the * all-hostile rule) — gets shoved (dir * 'push') or DRAGGED IN (dir 'pull'): an * impulse of `power` px/s on the hull, * no damage. * 'flank_barrage' (Zenith) the WING VOLLEY: every `cooldown` s a * salvo of `count` HEAT-SEEKERS flies out * of BOTH WINGS at 90° off the craft's * heading (missileSpread fan per side), * homing on a SLOW capped turn-rate * (missileTurn — the dodge rule, set much * lower than the nose seekers so a hard * pull beats them). The volley rides the * shared missile roster (ctx.missiles — * Weapons.js's step + hit passes own it). * 'shock_dart' (Zenith) the VOLT LINE: every `cooldown` s `count` * fast darts are thrown at the player; * each detonates when it reaches within * `proximity` of the target (or its fuse * runs) and blooms an ELECTRIFIED ZONE — * a disc that grows startRadius→maxRadius * over growMs, holds, then fades out at * zoneLifeMs. Anything inside the live * disc takes `tickDamage` every `tickMs` * (the DoT — dodgeable, finite, visible). * The darts (ctx.shockdarts) and zones * (ctx.shockzones) live on the context; * stepShock() (this module) is the shared * frame step (CombatSystem.update runs it * beside stepTkwaves / stepIllusions). * * The per-ability clocks ride the ship (e._ab[id]) — the config is the * ship's own (a type's abilities are its own, never shared state), and * the pure geometry helpers (behindPoint / offsetPoint / pushImpulse) * take an injectable rng so the tests pin them. */ import Phaser from '../vendor/phaser.js'; import { combatSfx } from './CombatSfx.js'; import { Missile } from './Missile.js'; /** Normalize a config's abilities into the live list (unknown ids skipped). */ export function abilitiesOf(cfg) { const list = cfg && Array.isArray(cfg.abilities) ? cfg.abilities : null; if (!list) return []; const out = []; for (const s of list) { if (s && typeof s.id === 'string' && s.id !== '' && HANDLERS[s.id]) out.push({ ...s }); } return out; } const TAU = Math.PI * 2; const num = (v, fb) => (Number.isFinite(Number(v)) ? Number(v) : fb); const col = (v, fb) => { const n = parseInt(String(v ?? '').replace('#', ''), 16); return Number.isFinite(n) && n !== 0 ? n : fb; }; // ------------------------------------------------------------ pure geometry /** * A point `radius` behind (px, py) on the craft's `facing`, jittered * ±`spread` rad and scaled 0.85–1.15 (the cloak's stand / the flank's * strike point — the bad side, never the nose). */ export function behindPoint(px, py, facing, radius, spread = 0.6, rng = Math.random) { const a = facing + Math.PI + (rng() * 2 - 1) * spread; const r = radius * (0.85 + rng() * 0.3); return { x: px + Math.cos(a) * r, y: py + Math.sin(a) * r }; } /** * A point minR–maxR from (px, py): with probability `biasBehind` it lands * on the rear arc (facing + π ± 0.9 rad — the blink prefers the bad side), * else a uniform bearing (a blink that teleports forward is still a blink). */ export function offsetPoint(px, py, facing, minR, maxR, biasBehind = 0, rng = Math.random) { const r = minR + rng() * Math.max(0, maxR - minR); const a = biasBehind > 0 && rng() < biasBehind ? facing + Math.PI + (rng() * 2 - 1) * 0.9 : rng() * TAU; return { x: px + Math.cos(a) * r, y: py + Math.sin(a) * r }; } /** * The telekinetic impulse a wave deals at arrival: `power` px/s AWAY from * the wave centre (dir 'push') or TOWARD it (dir 'pull'). Pure — the * CombatSystem applies it to the player's body. */ export function pushImpulse(wave, player) { const dx = player.x - wave.x; const dy = player.y - wave.y; const d = Math.hypot(dx, dy) || 1; const s = (wave.dir === 'pull' ? -1 : 1) * Math.max(0, num(wave.power, 0)); return { vx: (dx / d) * s, vy: (dy / d) * s }; } // ------------------------------------------------------------- the rosters /** * A Mindweave ILLUSION (the decoy roster, ctx.illusions): a visual copy of * the weaver (its sheet + frame + scale, a violet hologram tint) that * stands for lifeMs, shimmers while it does, and — while it lives — is a * TARGET in the player's auto-fire pool (its phantoms absorb the * cannon's tracers; collide pops one on contact). */ export function spawnIllusion(ctx, x, y, e, lifeMs = 2000, tintHex = '#9d4dff') { if (!Array.isArray(ctx.illusions)) ctx.illusions = []; let sprite = null; const key = e && e.texture && e.texture.key ? e.texture.key : null; if (key && ctx.scene && typeof ctx.scene.textures?.exists === 'function' && ctx.scene.textures.exists(key)) { try { sprite = new Phaser.GameObjects.Sprite(ctx.scene, x, y, key, e.sheetFrame ?? 0); if (typeof ctx.scene?.add?.existing === 'function') ctx.scene.add.existing(sprite); sprite.setDepth(9); // just under the real craft try { sprite.setScale(e.scale ?? 1); } catch { /* stub scene */ } try { sprite.setTint(col(tintHex, 0x9d4dff)); } catch { /* stub scene */ } } catch { sprite = null; } // a stub scene without the object layer: the phantoms stand as data } ctx.illusions.push({ sprite, x, y, hitRadius: Math.max(14, Number(e?.hitRadius) || 16), lifeMs: Math.max(1, num(lifeMs, 2000)), until: null, // the caller stamps it on the scene's clock dead: false, factionId: e && typeof e.factionId === 'string' ? e.factionId : null, // the court's faction (the all-hostile pop rule) }); return ctx.illusions[ctx.illusions.length - 1]; } /** * The illusions' frame step (CombatSystem.update's 0b): the shimmer while * the stand holds, the fade in the last 250 ms, the pop at expiry. */ export function stepIllusions(ctx, time) { const ils = ctx.illusions; if (!ils || ils.length === 0) return; const keep = []; for (const d of ils) { if (d.dead) continue; if (d.until != null && time >= d.until) { d.dead = true; const c = 0x9d4dff; if (ctx.fx) { ctx.fx.flash(d.x, d.y, c, 40, 180); ctx.fx.sparks(d.x, d.y, c, 8, 200, 420); } if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); continue; } const spr = d.sprite; if (spr && typeof spr.setAlpha === 'function') { const left = d.until != null ? d.until - time : 250; const fade = d.until != null ? Math.min(1, left / 250) : 1; spr.setAlpha(fade * (0.55 + 0.15 * Math.sin(time * 0.02))); } keep.push(d); } ctx.illusions = keep.filter((d) => !d.dead); } /** * The telekinetic WAVES' frame step (CombatSystem.update's 0b): each wave * blooms to its radius over travelMs; at arrival, EVERY hostile standing * in its reach — the player AND rival-faction craft (the all-hostile * rule; the caster's own wing and cloaked craft stand) — gets the * impulse (push/pull), and the ring's beat plays either way. */ export function stepTkwaves(ctx, time, player) { const ws = ctx.tkwaves; if (!ws || ws.length === 0) return; const keep = []; for (const w of ws) { if (time < w.until) { keep.push(w); continue; } const c = w.colorInt ?? 0x9d4dff; if (player && Math.hypot(player.x - w.x, player.y - w.y) <= w.r) { const imp = pushImpulse(w, player); if (player.body) { player.body.velocity.x += imp.vx; player.body.velocity.y += imp.vy; } if (ctx.fx) { ctx.fx.flash(player.x, player.y, c, 44, 220); ctx.fx.sparks(player.x, player.y, c, 10, 240, 460); } if (ctx.scene) sfx(ctx.scene, w.dir === 'pull' ? 'hull' : 'blink'); } // THE ALL-HOSTILE TIDE (the hand is built around EVERY enemy, not // just the ship): every live craft standing in the wave's reach takes // the same shove — or pull (a tide that drags the rival's wing into // the weaver's ring). The caster's own wing stands (no friendly // tide), and a cloaked craft stays phased (the cloak's unhittable // rule covers the hands too). if (Array.isArray(ctx.enemies)) { for (const s of ctx.enemies) { if (s.dead || s.cloaked) continue; if (s.factionId && s.factionId === w.factionId) continue; if (Math.hypot(s.x - w.x, s.y - w.y) > w.r) continue; const imp = pushImpulse(w, s); if (s.body) { s.body.velocity.x += imp.vx; s.body.velocity.y += imp.vy; } if (ctx.fx) ctx.fx.sparks(s.x, s.y, c, 8, 200, 400); } } if (ctx.fx) ctx.fx.ring(w.x, w.y, c, Math.max(60, w.r * 0.9), 420); } ctx.tkwaves = keep; } /** One SFX event through the combat voice (js/combat/CombatSfx.js). */ function sfx(scene, event) { try { combatSfx(scene, event); } catch { /* headless — no voice */ } } // ------------------------------------------------------- the Zenith darts /** * A scene circle for a persistent read (the shock zones / the darts) — * a full 360° Arc sized per frame. Null on a stub scene (the data still * lives — the same degraded-not-broken rule as spawnIllusion's sprite). */ function sceneCircle(ctx, x, y, r, colorInt, fillAlpha) { const scene = ctx && ctx.scene; if (!scene || typeof scene.add?.existing !== 'function') return null; try { const arc = new Phaser.GameObjects.Arc(scene, x, y, r, 0, 360); scene.add.existing(arc); arc.setFillStyle(colorInt, fillAlpha); arc.setStrokeStyle(2, colorInt, 0.85); arc.setDepth(6); // the zone under the craft (the illusions sit at 9) return arc; } catch { return null; } } /** * Detonate a dart at its position: the ring's beat + the zone's birth. * The zone keeps its own clock (born → grown at born+growMs → gone at * born+zoneLifeMs); the damage ticks ride the same object (stepShock). */ function detonateShock(ctx, time, d) { const z = d.zone; if (!Array.isArray(ctx.shockzones)) ctx.shockzones = []; const colorInt = z.colorInt ?? 0x7dfcff; const zone = { x: d.x, y: d.y, r: z.startR, startR: z.startR, maxR: z.maxR, growMs: z.growMs, born: time, end: time + z.lifeMs, fadeMs: Math.min(z.fadeMs, Math.max(1, z.lifeMs - 1)), tickMs: z.tickMs, tickDamage: z.tickDamage, nextTick: time, sprite: sceneCircle(ctx, d.x, d.y, Math.max(4, z.startR), colorInt, 0.16), }; ctx.shockzones.push(zone); const c = colorInt; if (ctx.fx) { ctx.fx.ring(d.x, d.y, c, Math.max(40, z.startR * 1.2), 380); ctx.fx.sparks(d.x, d.y, c, 10, 240, 460); } sfx(ctx.scene, 'impact'); } /** * The ZENITH shock layer's frame step (the shared pass — CombatSystem. * update calls it beside stepTkwaves/stepIllusions; both rosters live on * the ctx host): the DARTS fly their short fuse and detonate on * proximity (or the fuse end); the ZONES grow startRadius→maxRadius over * growMs (ease-out), hold, tick damage on a player inside the live disc, * fade their alpha in the last fadeMs, and die at `end`. Pure-ish: the * only host seams are ctx.shockdarts / ctx.shockzones, ctx.fx, and * ctx.damagePlayer. * * @param {object} ctx the owning context (a CombatSystem) * @param {number} time the combat clock (ms) * @param {object|null} player the player ship (null = down) * @param {number} [deltaMs] frame delta (ms) */ export function stepShock(ctx, time, player, deltaMs = 16) { const dt = Math.min(Math.max(0, deltaMs), 64) / 1000; // 1) THE DARTS — fly, then detonate: at `proximity` from the player // (after a short armed delay, so one never blooms on the muzzle) // or when the fuse ends (it dies where the line ran out). const darts = ctx.shockdarts; if (Array.isArray(darts) && darts.length > 0) { const keep = []; for (const d of darts) { d.x += d.vx * dt; d.y += d.vy * dt; // the dart's read (a small bright disc on the host scene) if (d.sprite && !d.sprite.destroyed) { d.sprite.x = d.x; d.sprite.y = d.y; if (Math.random() < 0.4 && ctx.fx) { ctx.fx.sparks(d.x, d.y, d.colorInt, 1, 90, 160); } } const near = player && Math.hypot(player.x - d.x, player.y - d.y) <= d.proximity; if ((near && time >= d.armedAt) || time >= d.until) { if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); detonateShock(ctx, time, d); } else { keep.push(d); } } ctx.shockdarts = keep; } // 2) THE ZONES — grow, hold, tick the DoT, fade, die. const zones = ctx.shockzones; if (!Array.isArray(zones) || zones.length === 0) return; const keep = []; for (const z of zones) { if (time >= z.end) { if (z.sprite && !z.sprite.destroyed) z.sprite.destroy(); continue; } const gp = Math.min(1, Math.max(0, (time - z.born) / Math.max(1, z.growMs))); z.r = z.startR + (z.maxR - z.startR) * (1 - Math.pow(1 - gp, 2)); // ease-out bloom if (z.sprite && !z.sprite.destroyed) { z.sprite.displayRadius = z.r; const fade = Math.max(0, Math.min(1, (z.end - time) / z.fadeMs)); z.sprite.setAlpha(0.3 + 0.7 * fade); } // the crackle while it feeds (a few sparks on the rim, not every frame) if (gp < 1 && Math.random() < 0.25 && ctx.fx) { const a = Math.random() * Math.PI * 2; ctx.fx.sparks(z.x + Math.cos(a) * z.r, z.y + Math.sin(a) * z.r, z.colorInt ?? 0x7dfcff, 2, 120, 220); } // THE DO-T: a player inside the live disc takes the tick (its own // clock — a zone that blooms under the ship still must leave room to // peel out before the next beat lands). if ( player && typeof ctx.damagePlayer === 'function' && time >= (z.nextTick ?? 0) && Math.hypot(player.x - z.x, player.y - z.y) <= z.r ) { z.nextTick = time + z.tickMs; const bearing = Math.atan2(z.y - player.y, z.x - player.x); ctx.damagePlayer(z.tickDamage, { x: z.x, y: z.y, bearing }); sfx(ctx.scene, 'shield'); // the electric buzz (the zone's voice) } keep.push(z); } ctx.shockzones = keep; } // --------------------------------------------------------------- the hands const HANDLERS = { cloaked_ambush: (ctx, e, player, time, st, a) => { const dur = num(a.cloakMs, 3000); const reveal = (t, strike) => { e.cloaked = false; try { e.setAlpha(1); } catch { /* stub */ } st.revealed = true; if (strike) e.nextFireAt = 0; // the reveal IS the strike — the first volley is free if (ctx.fx) { ctx.fx.ring(e.x, e.y, col(a.color, 0xff4d2d), 110, 420); ctx.fx.sparks(e.x, e.y, col(a.color, 0xff4d2d), 10, 260, 520); } sfx(ctx.scene, 'fire'); if (a.recloakCooldown > 0) st.recloakAt = t + a.recloakCooldown * 1000; }; if (e.cloaked) { if (time >= st.cloakUntil) reveal(time, true); return; } if (!st.revealed) { // THE FIRST CONTACT: the predator phases to its cloak stand — a // nearby point, usually on the player's rear arc — and waits. const p = behindPoint(player.x, player.y, ctx.playerFacing(), num(a.revealRadius, 220), 0.9); ctx.teleportCraft(e, p.x, p.y); e.cloaked = true; try { e.setAlpha(0.16); } catch { /* stub */ } st.cloakUntil = time + dur; if (ctx.fx) ctx.fx.ring(p.x, p.y, col(a.color, 0xff4d2d), 80, 380); return; } // THE STALKER'S NAP: recloak on its own clock (the signature). if (a.recloakCooldown > 0 && time >= (st.recloakAt ?? 0) && st.recloakAt != null) { e.cloaked = true; try { e.setAlpha(0.16); } catch { /* stub */ } st.cloakUntil = time + num(a.recloakMs, 2600); st.recloakAt = time + a.recloakCooldown * 1000; if (ctx.fx) ctx.fx.ring(e.x, e.y, col(a.color, 0xff4d2d), 70, 320); } }, flank_strafe: (ctx, e, player, time, st, a) => { if (e.orbitOverride && time < e.orbitOverride.until) return; // holding the cut const period = num(a.period, 11) * 1000; if (st.nextAt == null) st.nextAt = time + period * (0.4 + Math.random() * 0.6); if (time < st.nextAt) return; st.nextAt = time + period; // THE CUT: behind the player (the player-facing's rear arc), hold it. const p = behindPoint(player.x, player.y, ctx.playerFacing(), num(a.radius, 210), num(a.spread, 0.7)); e.orbitOverride = { x: p.x, y: p.y, until: time + num(a.holdMs, 1600) }; if (ctx.fx) ctx.fx.sparks(p.x, p.y, col(a.color, 0xff9d2d), 6, 160, 380); sfx(ctx.scene, 'impact'); }, blink_reposition: (ctx, e, player, time, st, a) => { const cd = num(a.cooldown, 12) * 1000; if (st.nextAt == null) st.nextAt = time + cd * (0.35 + Math.random() * 0.4); if (time < st.nextAt) return; st.nextAt = time + cd * (0.8 + Math.random() * 0.4); const fromX = e.x, fromY = e.y; const to = offsetPoint( player.x, player.y, ctx.playerFacing(), num(a.minRadius, 200), num(a.maxRadius, 330), num(a.biasBehind, 0.4), ); const il = spawnIllusion(ctx, fromX, fromY, e, num(a.decoyMs, 1800)); il.until = time + num(a.decoyMs, 1800); ctx.teleportCraft(e, to.x, to.y); const c = col(a.color, 0x9d4dff); if (ctx.fx) { ctx.fx.ring(fromX, fromY, c, 110, 380); ctx.fx.ring(to.x, to.y, c, 110, 380); ctx.fx.sparks(to.x, to.y, c, 12, 260, 520); } sfx(ctx.scene, 'blink'); }, illusion_decoy: (ctx, e, player, time, st, a) => { const cd = num(a.cooldown, 16) * 1000; if (st.nextAt == null) st.nextAt = time + cd * 0.5; if (time < st.nextAt) return; st.nextAt = time + cd; const n = Math.max(1, Math.floor(num(a.count, 2))); const r = num(a.radius, 170); const life = num(a.lifeMs, 2600); for (let i = 0; i < n; i++) { const ang = (i / n) * TAU + Math.random() * 0.8; const rr = r * (0.6 + Math.random() * 0.5); const il = spawnIllusion(ctx, e.x + Math.cos(ang) * rr, e.y + Math.sin(ang) * rr, e, life); il.until = time + life; } const c = col(a.color, 0x9d4dff); if (ctx.fx) { ctx.fx.ring(e.x, e.y, c, 120, 420); ctx.fx.sparks(e.x, e.y, c, 10, 220, 460); } sfx(ctx.scene, 'blink'); }, telekinetic_wave: (ctx, e, player, time, st, a) => { const cd = num(a.cooldown, 14) * 1000; if (st.nextAt == null) st.nextAt = time + cd * 0.4; if (time < st.nextAt) return; st.nextAt = time + cd; if (!Array.isArray(ctx.tkwaves)) ctx.tkwaves = []; ctx.tkwaves.push({ x: e.x, y: e.y, r: num(a.radius, 280), dir: a.dir === 'pull' ? 'pull' : 'push', power: num(a.power, 260), until: time + num(a.travelMs, 650), colorInt: col(a.color, 0x9d4dff), factionId: typeof e.factionId === 'string' ? e.factionId : null, // the all-hostile tide's own-wing stand }); if (ctx.fx) ctx.fx.ring(e.x, e.y, col(a.color, 0x9d4dff), 60, 500); sfx(ctx.scene, 'shield'); }, flank_barrage: (ctx, e, player, time, st, a) => { // THE WING VOLLEY (Zenith's combo archetype): a salvo out of BOTH // WINGS at 90° off the craft's heading — each half a slight fan, and // every seeker homing on a SLOW capped turn-rate (missileTurn, set // far below the nose seekers' 2.2): they curve wide around the // player's pull, so the dodge is a committed turn, not a twitch. The // shots ride the shared missile roster (ctx.missiles) — Weapons.js's // stepMissiles / missilesHitPlayer own their flight and hits exactly // like the `missile`/`combo` patterns. const cd = num(a.cooldown, 14) * 1000; if (st.nextAt == null) st.nextAt = time + cd * (0.4 + Math.random() * 0.5); if (time < st.nextAt) return; st.nextAt = time + cd; const n = Math.max(1, Math.floor(num(a.count, 6))); const heading = typeof e.heading === 'function' ? e.heading() : Math.atan2(player.y - e.y, player.x - e.x); const perSide = Math.ceil(n / 2); const spread = Math.max(0, num(a.missileSpread, 0.25)); const nose = Math.max(10, Number(e.hitRadius) || 18) + 12; const colorInt = col(a.color, 0xffd24d); const opts = { speed: Math.max(40, num(a.missileSpeed, 220)), turnRate: Math.max(0.2, num(a.missileTurn, 0.9)), // the SLOW seek (the dodge rule) damage: Math.max(1, num(a.missileDamage, 16)), life: Math.max(0.5, num(a.missileLife, 5)), color: a.color ?? '#ffd24d', size: Math.max(10, num(a.missileSize, 18)), owner: 'enemy', // hostile seekers — the player is the intended victim }; if (!Array.isArray(ctx.missiles)) ctx.missiles = []; let launched = 0; for (const side of [1, -1]) { const base = heading + side * Math.PI / 2; // 90° off the nose, each wing for (let i = 0; i < perSide && launched < n; i++) { launched++; const f = perSide === 1 ? 0.5 : i / (perSide - 1); const ang = base + (f - 0.5) * spread; const mx = e.x + Math.cos(ang) * nose; const my = e.y + Math.sin(ang) * nose; if (ctx.fx) ctx.fx.sparks(mx, my, colorInt, 2, 120, 200, ang); // the wing's beat try { const m = new Missile(ctx.scene, { x: mx, y: my, angle: ang, ...opts }); m.targetRef = player; // the seeker's home target (stepMissiles's rule) // THE WAR-ZONE DISPOSITION (the shooter's stamp): a // neutral-to-the-player faction's wing seekers never re-home // onto the ship when their victim falls (Weapons.js's rule). m.hostileToPlayer = e.hostileToPlayer === true; // The shooter's FACTION (the crossfire pass's friendly-fire // rule — a faction's wing seekers drop the rival faction's // craft, never its own wing). m.factionId = typeof e.factionId === 'string' ? e.factionId : null; ctx.missiles.push(m); } catch { /* stub scene — the volley's beat still plays */ } } } if (launched > 0) sfx(ctx.scene, 'missile_fire'); // the rocket voice (the launcher's) }, shock_dart: (ctx, e, player, time, st, a) => { // THE VOLT LINE (Zenith's elemental archetype): `count` fast darts // thrown at the player, a tight fan (dartSpread) with a little jitter // (dartJitter). Each detonates near the target (within `proximity`) — // or at the fuse end — blooming the zone the config describes // (stepShock grows it and ticks the DoT). The dart itself is a small // bright disc on the host scene (sceneCircle), trailing sparks. const cd = num(a.cooldown, 12) * 1000; if (st.nextAt == null) st.nextAt = time + cd * (0.4 + Math.random() * 0.5); if (time < st.nextAt) return; st.nextAt = time + cd; const n = Math.max(1, Math.floor(num(a.count, 1))); const toPlayer = Math.atan2(player.y - e.y, player.x - e.x); const speed = Math.max(120, num(a.dartSpeed, 520)); const nose = Math.max(10, Number(e.hitRadius) || 18) + 12; const colorInt = col(a.color, 0x7dfcff); if (!Array.isArray(ctx.shockdarts)) ctx.shockdarts = []; for (let i = 0; i < n; i++) { const off = n === 1 ? 0 : (i - (n - 1) / 2) * num(a.dartSpread, 0.35); const ang = toPlayer + off + (Math.random() * 2 - 1) * num(a.dartJitter, 0.08); const x = e.x + Math.cos(ang) * nose; const y = e.y + Math.sin(ang) * nose; ctx.shockdarts.push({ x, y, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, until: time + num(a.dartLifeMs, 1300), // the fuse (detonate wherever it ends) armedAt: time + 150, // never blooms on the muzzle proximity: Math.max(10, num(a.proximity, 90)), colorInt, sprite: sceneCircle(ctx, x, y, 5, colorInt, 0.9), // the dart's read zone: { startR: Math.max(6, num(a.startRadius, 40)), maxR: Math.max(20, num(a.maxRadius, 150)), growMs: Math.max(200, num(a.growMs, 1800)), // the slow bloom lifeMs: Math.max(600, num(a.zoneLifeMs, 3200)), // finite, always fadeMs: Math.max(150, num(a.zoneFadeMs, 700)), tickMs: Math.max(120, num(a.tickMs, 400)), tickDamage: Math.max(1, num(a.tickDamage, 4)), colorInt, }, }); if (ctx.fx) ctx.fx.sparks(x, y, colorInt, 3, 160, 260, ang); } sfx(ctx.scene, 'fire'); // the thrown-arc voice (the launch beat) }, }; /** * One frame of the craft's abilities (called from CombatSystem.update's * pack loop, before the fire gate — a reveal this frame can strike this * frame). No player (down) = the hands stay closed (no strikes, no tides). */ export function stepAbility(ctx, e, player, time) { const list = e.abilities; if (!list || list.length === 0) return; if (!player || e.dead) return; for (const a of list) { const st = (e._ab[a.id] ??= {}); try { HANDLERS[a.id](ctx, e, player, time, st, a); } catch (err) { if (typeof console !== 'undefined') console.warn(`[orbit] ability "${a.id}" failed`, err); } } }