/** * 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, 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); } } }