645 lines
28 KiB
JavaScript
645 lines
28 KiB
JavaScript
/**
|
||
* 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, 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);
|
||
}
|
||
}
|
||
}
|