orbit/js/combat/EnemyAbilities.js

347 lines
14 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.
*
* 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';
/** 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 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');
},
};
/**
* 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);
}
}
}