orbit/js/combat/EnemyAbilities.js

615 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* The enemy ABILITY LAYER — the faction skills that ride on top of the
* weapon patterns (data/combat.json → enemyTypes.<type>.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, a player inside
* `radius` 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,
});
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 a player inside it 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');
}
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),
});
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;
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);
}
}
}