orbit/js/combat/CombatSystem.js

3023 lines
131 KiB
JavaScript
Raw Permalink 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.

import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { NeutralShip } from './NeutralShip.js';
import { TransportShip } from './TransportShip.js';
import { enemyTypeConfig, neutralTypeConfig, transportConfig, factionTypeIds } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
import { stepAbility, stepIllusions, stepTkwaves, stepShock } from './EnemyAbilities.js';
import { barrels as barrelLine, intercept } from './Aiming.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, burnDurationMs, burnTickDamage, dashPoint, decoyMs, flameTargetCount, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js';
import { iconSheet, seatCount, skillsEnabled } from '../skills/SkillModel.js';
import { perkDef } from '../player/ClassModel.js';
import { XpManager } from './XpManager.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { combatShake } from './CamShake.js';
import { normalizeWaves, WaveRunner } from './Waves.js';
import { assignWorld } from '../visuals/UiCameras.js';
import Phaser from '../vendor/phaser.js';
const TAU = Math.PI * 2;
/**
* COMBAT AS A SYSTEM — the portable half of the combat sandbox, built to
* live INSIDE an existing scene (the main game: js/scenes/GameScene.js)
* instead of owning one (the range: js/combat/CombatScene.js, which
* inlines the same rules for its fixed arena).
*
* It owns, per scene:
* - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*)
* - every tracer (Bullet — one-shot, life-limited)
* - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn,
* + the per-level bump from data/progression.json → reward)
* - the player's ENERGY (→ energy: the skills' resource — they spend it later)
* - the XP BANK (XpManager — the combat level + experience, the
* curve in data/progression.json; a kill grants the
* hostile's type `xp`, a level-up's beat — the armor
* bump, the burst, the scene's onLevelUp — rides the
* manager's 'levelup' EVENT; the skill layer subscribes
* to the same one later)
* - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the
* ship fires at the NEAREST one — the mouse steers,
* the cannon tracks; the cannon's numbers are → player.weapon)
* - the ON-SCREEN RULE (a hostile must be VISUALLY ON SCREEN to fire or
* be fired upon: an off-screen craft holds its lance,
* the cannon won't engage it, and its tracers pass
* through — it enters the fight only once it has
* flown into view; pure geometry in Combat.js → inView)
* - the collision pass (swept bullet hits + rams — the main game's
* hand-rolled circle-check style, no arcade colliders)
* - the WAVE RUNS (Waves.js state machine — quest-step combat: an
* ordered list of 'rolling' / 'fixed' waves, each a
* roster of enemyTypes with a total force. startWaves
* runs the script — lead, wave, gap, wave, … — and
* the kills feed the rolling replacements. The QUEST
* HOOK THAT FIRES startWaves LANDS LATER; until then
* the console drives it (GameScene → __ORBIT_COMBAT__.waves))
* - the PIRATE COMMS (the comming types' own voice — the chances are the
* type's, data/combat.json → enemyTypes.<type>.comms):
* a 50% roll the first time a pirate flies on-screen,
* a 20% roll when a new pirate is sent into a rolling
* wave (the opening burst, each kill's replacement), a
* 20% defeat roll on the kill. The comms themselves
* (data/comms.json's pirate-attack / pirate-defeat —
* the clip + the random speech clip + the line) are
* armed through o.onComm (the scene's CommsHub)
*
* The scene supplies the player ship (a Ship, or anything with
* .x/.y/.radius/.body/.stop()/.setVisible()) and calls
* update(time, delta) from its own update loop — the same "actors in
* update" contract GameScene already runs on (the engine steps the
* arcade world itself).
*
* Lifecycle: startWaves(script) starts the fight (the ?combat boot runs
* the 'combat_demo' seven-type sequence; the quest ambush runs its own
* script); spawnPack(cx, cy) still drops a one-shot practice pack;
* active flips on the first hostile / back off when the pack is wiped
* and not reforming — the scene uses onActiveChange to swap the action
* deck for the combat bar.
*/
export class CombatSystem {
/**
* @param {Phaser.Scene} scene a scene with arcade physics + add/textures
* @param {object} [o]
* ship the player ship (null = no player to hit/fire for)
* onActiveChange (active) => {} — combat live / over (the bar swap)
* onPlayerDamage (amount, armor) => {} — each hit on the player
* onPlayerDown () => {} — the player's hull broke
* onPlayerUp () => {} — the player reformed
* onKill (enemy) => {} — a hostile went down
* onLevelUp (level) => {} — a combat level landed (the feed's line;
* the system's own FX/SFX/armor fire on the event)
* onComm (trigger) => {} — a comming hostile rolled one of its
* comm lines (the pirate's attack/defeat — the scene
* arms its CommsHub's trigger; js/comms; a context
* without one simply omits the seam)
*/
constructor(scene, o = {}) {
this.scene = scene;
this.ship = o.ship ?? null;
this.onActiveChange = typeof o.onActiveChange === 'function' ? o.onActiveChange : null;
this.onPlayerDamage = typeof o.onPlayerDamage === 'function' ? o.onPlayerDamage : null;
this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null;
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null;
this.onComm = typeof o.onComm === 'function' ? o.onComm : null;
this._neutral = false; // the last _setActive's neutral-only flag (the seam's edge)
this._neutralScanCommed = false; // the encounter's scan comm already fired
this._neutralBreakCommed = false; // the encounter's scan-complete comm already fired
this.enemies = [];
this.allies = []; // neutral escorts (a separate roster — js/combat/NeutralShip.js)
this.bullets = [];
// THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): the homing
// stamp for listed factions' bolts (spawnBullet) + their bend (the
// stepHomingBullets pass, run in update before the collisions).
this.bulletHoming = config.section('combat.bulletHoming', {});
// The heat-seeker roster (js/combat/Weapons.js steps + hits them; the
// missiles themselves live in js/combat/Missile.js).
this.missiles = [];
this.kills = 0;
// The wave run in flight (Waves.js — null when no script is running).
this.waveRun = null;
this._spawnSerial = 0; // the orbit-dir alternation counter (pack + waves)
// The player's life (playerShip.* — both contexts share it). The BASE
// armor is playerShip's; the per-level bump (data/progression.json →
// reward) stacks on top of it, level by level — the placeholder
// toughness until the skill layer lands (a passive can override or
// stack these later).
const p = config.section('combat.playerShip', {});
this.baseHull = Math.max(1, Number(p.hull) || 100);
this.baseShields = Math.max(0, Number(p.shields) || 0);
const rw = config.section('progression.reward', {});
this.hullPerLevel = Math.max(0, Number(rw.hullPerLevel) || 0);
this.shieldPerLevel = Math.max(0, Number(rw.shieldPerLevel) || 0);
this.fullHealOnLevelUp = rw.fullHealOnLevelUp !== false;
// The XP bank (js/combat/XpManager.js — the curve is
// data/progression.json; each hostile carries its own type's value,
// combat.enemy.xp / the type's override). A scene may hand over a
// SHARED manager (o.xp — the main game's save state, restored from
// the ledger before the system builds); the manager is pure data,
// so it outlives any single scene.
this.xp = o.xp ?? new XpManager();
// A level's beat: the armor bump + the moment (the burst + the voice),
// then the scene's callback (the kill feed's line). The skill layer
// will subscribe to the manager's 'levelup' event itself — this is
// the system's share of it.
this._levelUnsub = this.xp.on('levelup', (ev) => this._onLevelUp(ev));
// Apply the CURRENT level's armor (a restored ledger's level — a load
// fires no events, so the bump lands here, not on the event), then
// settle the ship's VITALS: o.vitals (SaveData's record.combat
// hull/shields/energy, staged by prepareLoad as 'combatVitals')
// restores the DAMAGE the run was flying with, clamped to this
// level's max — a load is not a repair. Missing or invalid numbers
// (a save predating the fields, combat off, a fresh run) fall back
// to the fresh ship (full armor). The full repair instead happens on
// a WORLD LANDING (GameScene.startLanding → repairFull) and a
// SYSTEM ARRIVAL (a jump's 'combatRepair' flag — the destination's
// dock stands in for the cut).
this._applyArmorToLevel();
const vit = o.vitals && typeof o.vitals === 'object' ? o.vitals : null;
const settle = (n, max) => {
const x = Number(n);
return Number.isFinite(x) ? Math.max(0, Math.min(max, x)) : max;
};
// A restored run is ALIVE — the hull floors at 1 (a hull of 0 is a
// death, and death ends the run; a degenerate save captured on the
// dying frame must still load as a flying ship).
this.hull = Math.max(1, settle(vit?.hull, this.maxHull));
this.shields = settle(vit?.shields, this.maxShields);
this.playerRespawn = p.respawn !== false;
this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000;
this.playerDown = false;
this.respawnAt = 0;
this.lastHitAt = -1e9;
// ENERGY — the skill resource (→ energy). Skills (keys 1-8, the bar's
// middle seats) spend it; for now it just sits full, and the bar
// shows it.
const en = config.section('combat.energy', {});
this.maxEnergy = Math.max(0, Number(en.max) || 100);
this.energy = settle(vit?.energy, this.maxEnergy); // the drain rides the load too
this.energyRegen = Math.max(0, Number(en.regen) || 0);
// Main-game combat (→ game).
const g = config.section('combat.game', {});
this.spawnCount = Math.max(0, Number(g.spawnCount) || 3);
this.spawnRadius = Math.max(60, Number(g.spawnRadius) || 340);
this.autoFire = {
enabled: g.autoFire?.enabled !== false,
range: Math.max(0, Number(g.autoFire?.range) || 800),
};
// The rams' restitution (the range's arena bounce reads as a general
// "what a collision keeps of the speed" fraction — combat.arena.bounce).
this.restitution = Math.min(1, Math.max(0, Number(config.get('combat.arena.bounce', 0.9)) || 0.9));
this.ramUntil = 0;
this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp
// THE CLASS SEAM (js/player/ClassState.js, set by GameScene on lock-in and
// on load). Null here means NO class: the training range (combat.html) never
// sets it, so the range keeps its base single-cannon, raw-aim feel, and an
// unclassed main-game run fires exactly the old shot. The two perks this
// ledger can carry change the player's volley only (data/classes.json →
// perks): 'dualCannon' adds barrels, 'targetingComputer' leads the target.
this.classState = null;
// THE SKILL SEAM (js/skills/SkillState.js, handed over by GameScene on
// build, on lock-in and on load — the same way the class ledger arrives).
// Null here means the fight has no skills: the training range (combat.html)
// never sets it, so keys 1-8 stay the empty seats they have always been.
this.skillState = o.skillState ?? null;
// ONE RECHARGE CLOCK PER SKILL (seconds left — data/skills.json →
// skills[].cooldown), ticked by update() and read by the bar for its sweep.
// It is keyed by skill, not by seat: moving a skill to another seat mid-
// recharge does not hand back the wait.
this.skillCooldowns = new Map();
// The charge in flight (data/skills.json → effect.kind 'dashBlast'), or
// null. While it is set the player's steering is held off — the skill owns
// the hull for its duration and lands the blast where it stops.
this.dash = null;
// The charge's comet head (a LIVE FX handle — js/combat/Fx.js), kept
// OUT of the `dash` record on purpose: that record is spread into
// cast()'s public result (and console-serialized), so it stays a plain
// data bag. The handle lives here, released in _endDash.
this._dashComet = null;
// THE WRAITH'S SNARE (data/skills.json → effect.kind 'boleadoras'): the
// bola sets in flight — each one a pair of orbs on a string hunting its
// assigned craft on the advanced targeting's intercept solve, and
// dropping the stun (effect.disableBaseMs + disablePerRankMs × rank) on
// whatever it catches when it lands. Stepped in update()'s step 0b.
this.bolas = [];
// THE WRAITH'S ESCAPE (data/skills.json → effect.kind 'blink'): the
// live decoys — a copy of the player's own hull left at the old position,
// carrying every pirate's aggro while it stands (effect.decoyBaseMs +
// decoyPerRankMs × rank). Stepped in update()'s step 0b, gone in destroy().
this.decoys = [];
// THE VANGUARD'S FIRE (data/skills.json → effect.kind 'flameArc'): the
// flame arcs in flight — one wide wave per nearest hostile, each hunting
// its craft and igniting whatever it touches on arrival (the burn they
// stamp on the caught craft is stepped by _stepBurns, below). Stepped in
// update()'s step 0b, gone in destroy().
this.flames = [];
// THE ENEMY FACTION LAYER (data/combat.json → enemyTypes.<t>.abilities —
// js/combat/EnemyAbilities.js): the telekinetic WAVES in bloom (arrive
// on their clock, shove the player) and the MINDWEAVE ILLUSIONS (the
// decoy phantoms — shimmer, expire, and draw the auto-fire while live).
// Stepped in update()'s step 0b, gone in destroy().
this.tkwaves = [];
this.illusions = [];
// THE FACTION DUEL (the ?combat=apex / ?combat=mindweave boots): null
// unless a duel arena is live (startDuel); { faction, active } — the
// picker (js/combat/DuelPicker.js) re-shows when `active` drops (the
// marked fighter went down).
this.duel = null;
// THE NANOBOTS' clock (data/skills.json → effect.kind 'hullRegen'): ms
// accumulated toward the next hull mend (the data's intervalMs between
// mends, read live off the ledger in hullRegen()).
this._nanobotAccumMs = 0;
this.fx = new Fx(scene);
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
// holding the white hit-flash state is re-asserted back to its
// normal colors no matter how the stick got there.
this.fx.purgeRoster = () => [this.ship, ...this.enemies, ...this.allies];
this._active = false;
}
// ------------------------------------------------------------- the pack
/**
* Combat is live when a hostile is up (or one is on its reform clock),
* or a WAVE RUN is in flight (the lead + the gaps between waves keep the
* combat bar up — the fight is on even when the sky is clear).
*/
get active() {
const run = this.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return true;
return (
this.enemies.some((e) => (!e.dead && !e.gone) || (e._respawnPending && e.respawnAt > 0)) ||
// A live escort keeps the fight up too (its lances are live even
// when the pack is spent — the encounter ends when it's OFF). A
// PERSISTENT ally (the escort contract's transport —
// js/combat/TransportShip.js) does NOT: it stands for the life of
// the contract, so it can't hold the jump gate / the landing door
// open — the fight is over the moment the last pirate is down.
this.allies.some((a) => !a.persistent && !a.dead && !a.gone && !a._removed)
);
}
/**
* Is the LIVE roster a NEUTRAL-ONLY encounter? — at least one escort in,
* zero hostiles anywhere (not even pending respawn). This is the gate
* for the neutral soundtrack + the pilot's scan comms: the moment the
* last pirate dies with escorts still escorting, it stands.
*/
_neutralOnly() {
const enemiesLive = this.enemies.some((e) => e && !e.dead);
const alliesLive = this.allies.some((a) => a && !a.persistent && !a.dead && !a.gone && !a._removed);
return alliesLive && !enemiesLive;
}
/**
* Fire the state change at the scene (bar swap) when it flips — now
* WITH the neutral-only flag, so a neutral swap (active → active, or
* active → over) re-runs the scene's music seam (combat ↔ neutral ↔
* deep-space) even when the plain active flag didn't move (last pirate
* down, escorts still escorting).
*/
_setActive() {
const a = this.active;
const n = a && this._neutralOnly();
if (a === this._active && n === this._neutral) return;
this._active = a;
this._neutral = n;
if (this.onActiveChange) this.onActiveChange(a, n);
}
/**
* The distance from the ship at which a fresh hostile materializes:
* OFF-SCREEN, so it flies into view and attacks instead of popping in.
* That is the furthest of the spawn band (game.spawnRadius) and the
* camera's visible edge + the fly-in lead (game.spawnLead px past the
* worldView — already in world px, zoom included). A scene without a
* camera (the headless tests) gets the plain band.
*/
_offscreenRadius() {
const cam = this.scene?.cameras?.main;
const lead = Math.max(0, Number(config.get('game.spawnLead', 140)) || 0);
if (cam && cam.worldView && cam.worldView.width > 0) {
const halfDiag = Math.hypot(cam.worldView.width, cam.worldView.height) / 2;
return Math.max(this.spawnRadius, halfDiag + lead);
}
return this.spawnRadius;
}
/**
* The camera's visible world rect (world px, zoom included) — the
* surface the ON-SCREEN RULE tests against. Null when the scene has no
* camera (the headless tests): then nothing is off-screen and the rule
* is a no-op.
*/
visibleView() {
const cam = this.scene?.cameras?.main;
const v = cam && cam.worldView ? cam.worldView : null;
return v && v.width > 0 && v.height > 0 ? v : null;
}
/**
* The ON-SCREEN RULE (the pure geometry is Combat.js → inView): a
* hostile is "on screen" while ANY part of its visual disc (its
* hitRadius, or half its hull length — whichever is wider) still
* touches the camera's visible rect. Off-screen hostiles are OUT of
* the fight: they hold their lances, the cannon won't engage them, and
* their tracers pass through (they re-enter the moment they fly in).
*/
craftVisible(e) {
if (!e) return false;
const r = Math.max(Number(e.hitRadius) || 0, (Number(e.size) || 0) * 0.5);
return inView(e.x, e.y, r, this.visibleView());
}
/**
* Spawn the starting pack in a ring around (cx, cy) — the ?combat boot
* calls this with the ship's position "when the player starts". The ring
* sits OFF-SCREEN (_offscreenRadius): the pack streams in on its own
* chase AI instead of materializing on the player.
* @returns {EnemyShip[]} the fresh hostiles
*/
spawnPack(cx, cy) {
const out = [];
for (let i = 0; i < this.spawnCount; i++) {
const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6;
const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2);
out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i));
}
return out;
}
/**
* One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its
* bearing. FACTION roster fighters (isFactionType) get their combat
* damage/armor at the player's current combat level (the
* combat.factionScaling ramp — the 2×/3.5× pirate ratio scales down to
* 1.2×/2.0× at level 10; js/combat/EnemyTypes.js →
* enemyTypeConfig(type, level)).
* @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base
*/
spawnEnemy(x, y, index, type = null) {
const level = this.xp ? this.xp.level : null;
const cfg = enemyTypeConfig(type, level);
const e = new EnemyShip(this.scene, x, y, index, cfg);
// The comm rolls' config (data/combat.json → enemyTypes.<type>.comms —
// the pirate's lines; null for a type without one, then every roll is
// a no-op). The roll's marks ride the ship (the comm section below).
e.comms = cfg.comms && typeof cfg.comms === 'object' ? cfg.comms : null;
e._commedAttack = false; // an attack line already fired (the rolls don't stack)
e._wasOnScreen = false; // the on-screen roll's edge (fires once, on entry)
e.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
e.orbitAngle = Math.atan2(y - py, x - px);
e.nextFireAt = 1000; // a brief non-shooting grace (ms clock)
this.enemies.push(e);
this._setActive();
return e;
}
/**
* One NEUTRAL ESCORT (data/combat.json → neutralTypes — frames 20–27
* of the enemy sheet, the player-side roster): materializes
* off-screen (the caller places it — the encounter director spawns it
* in the _offscreenRadius band), then streams in on its own escort AI
* (js/combat/NeutralShip.js).
*
* `o.escortMs` — this ship's escort window (ms); omitted = a per-ship
* roll inside neutral.escortMin..escortMax ("each neutral ship flies
* near the player for a random 10–15 s"); `o.viewFn` overrides the
* break-off cull surface (headless tests).
* @returns {NeutralShip}
*/
spawnAlly(x, y, index, type = null, o = {}) {
const cfg = o && o.cfg && typeof o.cfg === 'object' ? o.cfg : neutralTypeConfig(type);
const base = config.section('combat.neutral', {});
const emin = Math.max(1, Number(base.escortMin) || 10);
const emax = Math.max(emin, Number(base.escortMax) || 15);
const oE = Number(o.escortMs);
// A positive window (or +Infinity — the escort contract's convoy crew /
// transport never lapse) is taken as-is; otherwise roll the neutral band.
const escortMs = oE > 0 ? oE : (emin + Math.random() * (emax - emin)) * 1000;
const a = new NeutralShip(this.scene, x, y, index, cfg, {
escortMs,
viewFn: typeof o.viewFn === 'function' ? o.viewFn : () => this.visibleView(),
});
a.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
a.orbitAngle = Math.atan2(y - py, x - px); // anchor on the spawn bearing
a.nextFireAt = 1000; // the pack's grace (ms clock)
this.allies.push(a);
this._setActive();
return a;
}
/**
* The ESCORT contract's TRANSPORT (data/combat.json → transport —
* assets/images/ships-transport.png, frames 0–3): a PERSISTENT
* player-side freighter (js/combat/TransportShip.js). The break-off
* window is lapsed (it stays for the life of the contract), and it is
* flagged `persistent` — the `active` / `_neutralOnly` gates above skip
* it, so a standing convoy never holds the jump gate or the landing
* door. Degraded boot (no transport config) still flies: a persistent
* NEUTRAL craft stands in so the contract's route doesn't break.
* @returns {TransportShip|NeutralShip}
*/
spawnTransport(x, y, index, c = null) {
const cfg = c && typeof c === 'object' ? c : transportConfig();
if (!cfg) {
const a = this.spawnAlly(x, y, index, null, { escortMs: Number.POSITIVE_INFINITY });
a.persistent = true;
return a;
}
const a = new TransportShip(this.scene, x, y, index, cfg, { viewFn: () => this.visibleView() });
a.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
a.orbitAngle = Math.atan2(y - py, x - px); // anchor on the spawn bearing
a.nextFireAt = 1000; // the pack's grace (ms clock)
this.allies.push(a);
this._setActive();
return a;
}
// ----------------------------------------------------------------- waves
/**
* Start a WAVE SCRIPT — the quest-step / random-encounter combat (the
* quests fire this when their hook lands; for now the console drives it:
* window.__ORBIT_COMBAT__.waves.demo('raid')).
*
* `script` is an ordered list of waves, `{ waves: [...], lead?, gap? }`
* (seconds), or a NAME under combat.waves.scripts. Kinds: 'rolling' =
* maxConcurrent on screen at a time, each kill sending in
* replacePerKill more until the wave's force is spent; 'fixed' = the
* whole force at once, no replacements. The roster mixes the
* enemyTypes' ids by weight (type/count is the single-type shorthand).
*
* A wave may declare ONE elite (`elite: { type, healthScale?, damageScale?,
* name? }`) — a named, buffed craft deployed with the opening group and
* tagged on the ship (`e.elite`, `e.markName`) so a quest can recognise its
* kill; see js/combat/Waves.js + EnemyShip.applyEliteBuff.
*
* Wave ships are RUN-OWNED: they never self-respawn (the force gets
* spent — that's the point), and each kill feeds the runner's rolling
* replacements (killEnemy). The bar stays up from the lead through the
* last kill. Callbacks (opts): onWaveStart(index, wave), onWaveClear
* (index, wave), onComplete(status) — the quest hook's 'combat over'
* seam — and onCancel().
*
* Every wave ship — the first burst (fixed AND rolling) and the rolling
* replacements alike — materializes OFF-SCREEN around the player, from
* a random direction (_spawnWaveEnemy), and streams in on its own chase
* AI: the wave never pops on top of the player.
*
* @param {Array|object|string} script the wave script (data/combat.json → waves)
* @param {object} [opts] the callbacks (above)
* @returns {object|null} the run (query via waveStatus()) — null on a bad script
*/
startWaves(script, opts = {}) {
const norm = normalizeWaves(script);
if (!norm.ok) {
if (typeof console !== 'undefined') console.warn(`[orbit] waves: ${norm.error}`);
return null;
}
this.cancelWaves(true); // a new fight replaces the old one (its ships go down)
const cb = (f) => (typeof f === 'function' ? f : null);
this.waveRun = {
runner: new WaveRunner(norm),
state: 'lead',
nextAt: null, // set on the first tick (the clock is the scene's update time)
lead: norm.lead,
onWaveStart: cb(opts.onWaveStart),
onWaveClear: cb(opts.onWaveClear),
onComplete: cb(opts.onComplete),
onCancel: cb(opts.onCancel),
};
this._setActive(); // the bar is up from the lead (the warning window)
return this.waveRun;
}
/**
* Cancel the run in flight. `destroy` also pulls the wave's ships out of
* the world (they just go — no kill FX, no feed entry).
* @param {boolean} [destroy] destroy the run's ships too
* @returns {object|null} the run that was cancelled (null when none)
*/
cancelWaves(destroy = false) {
const run = this.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') {
run.state = 'cancelled';
if (run.onCancel) run.onCancel();
}
if (destroy) {
for (const e of this.enemies) {
if (e.wave && !e.destroyed) {
this.fx.releaseCraft(e); // out in normal colors, never mid-flash
e.destroy();
}
}
this.enemies = this.enemies.filter((e) => !e.wave);
}
this._setActive();
return run;
}
/**
* A status snapshot (the console's waves.status() / the quest hook's UI).
* Null when no run. `state` is the RUN's state (lead/gap/live/done/
* cancelled) — the runner's own view is lead/live, the run carries the
* clocks + the terminal states.
*/
waveStatus() {
const run = this.waveRun;
if (!run) return null;
const s = run.runner.status();
const now = this._now();
return {
...s,
state: run.state,
nextIn: run.nextAt != null ? Math.max(0, run.nextAt - now) / 1000 : null,
};
}
/**
* One wave ship, bearing in on its own chase AI. It materializes
* OFF-SCREEN around the PLAYER — a random direction from the ship at
* _offscreenRadius's band (the same off-screen materialization as the
* ?combat pack): neither the first burst (fixed or rolling) nor the
* rolling replacements ever pop on top of the player; the chase AI
* streams each one in from whichever way it came out.
* A spawn ENTRY is a type id, or an elite object from the wave's `elite`
* block (`{ type, healthScale, damageScale, name }`) — an elite gets the
* buff applied (EnemyShip.applyEliteBuff) and stays tagged.
*
* @param {string|object} entry the hostile to send (a combat.enemyTypes id, or an elite entry)
* @returns {EnemyShip} the fresh hostile (tagged `wave`)
*/
_spawnWaveEnemy(entry) {
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU; // a random direction from the player
const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2);
const elite = entry != null && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
const typeId = elite ? elite.type : entry;
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.wave = true; // owned by the run: no self-respawn, the kill feeds the runner
if (elite) e.applyEliteBuff(elite);
// THE ROLLING ARRIVAL (data/combat.json → comms.attack.spawnChance):
// a NEW pirate sent into a rolling wave (the opening burst, each
// kill's replacement) rolls its own attack comm on arrival (20%) — a
// hit marks the ship, so its later on-screen roll doesn't stack on top.
if (e.comms?.attack) this._commAttackRoll(e, e.comms.attack.spawnChance);
return e;
}
/**
* HALT the run in flight — the "the mark is down, stop sending the rest"
* seam a bounty hunt uses after it kills its target. Unlike cancelWaves it
* takes nothing out of the world: every live wave ship stays HOSTILE and
* must be flown down, but nothing else deploys (the wave's unspent force is
* dropped and no replacement follows) and none of them reform. When the last
* one drops the run reads 'done' (waveStatus), which is what the scene waits
* on before it completes the contract.
*
* @returns {object|null} the run that was halted (null when none / already over)
*/
haltWaves() {
const run = this.waveRun;
if (!run || run.state === 'done' || run.state === 'cancelled' || run.state === 'halted') return null;
run.state = 'halted';
run.nextAt = null;
for (const w of run.runner.waves) {
w.queue.length = 0; // nothing more deploys, replacements included
}
for (const e of this.enemies) {
if (e.wave && !e.destroyed) {
e.respawn = false; // the survivors fight it out — no reform clock
e._respawnPending = false;
e.respawnAt = 0;
}
}
this._setActive();
return run;
}
/**
* The run's state step (the tail of update()): lead/gap → deploy once
* the clock has run, live → hand a cleared wave over (the gap, or the
* end of the script). The kills already landed in killEnemy (the rolling
* replacements spawn there).
* @param {number} time the scene clock (ms)
*/
_waveTick(time) {
const run = this.waveRun;
if (!run || run.state === 'done' || run.state === 'cancelled') return;
const runner = run.runner;
// HALTED (the mark went down): no deployments, no replacements — the run
// is over when the ships still on screen go down with it.
if (run.state === 'halted') {
if (!this.enemies.some((e) => e.wave && !e.dead)) {
run.state = 'done';
this._setActive();
}
return;
}
// The current wave's force is spent → the gap (or the end of the run).
if (run.state === 'live' && runner.waveCleared()) {
if (run.onWaveClear) run.onWaveClear(runner.index, runner.current, this);
if (runner.hasNext) {
run.state = 'gap';
run.nextAt = time + runner.waves[runner.index + 1].gapMs;
} else {
run.state = 'done';
if (run.onComplete) run.onComplete(runner.status(), this);
}
this._setActive();
return;
}
// The clocks: the lead before wave 1, the gap between waves.
if (run.state === 'lead' || run.state === 'gap') {
if (run.nextAt == null) run.nextAt = time + run.lead;
if (time < run.nextAt) return;
const spawns = run.state === 'lead' ? runner.begin() : runner.next();
if (!spawns) {
run.state = 'done';
if (run.onComplete) run.onComplete(runner.status(), this);
this._setActive();
return;
}
for (const t of spawns) this._spawnWaveEnemy(t);
run.state = 'live';
if (run.onWaveStart) run.onWaveStart(runner.index, runner.current, this);
}
}
/**
* Recompute the max armor for the current level (the base + the level's
* per-level bump — data/progression.json → reward) and settle the live
* armor: clamped to the new max, refilled when fullHealOnLevelUp (a
* level-up's beat is a full heal; a restored ledger's level applies the
* same way on build — the method is level-driven, so both paths share it).
*/
_applyArmorToLevel() {
const level = this.xp ? this.xp.level : 0;
// THE PASSIVE SEAM (data/skills.json → effect.kind 'hullPool'): the level's
// armor is the BASE and a hull passive MULTIPLIES it. The product is rolled
// from base every single time — never off last frame's max — so a rank-up,
// a level-up bump, a combat restart and a save/load can each only state the
// answer, never stack it (100 hull at 5/5 Reinforced Plating is 125, once).
this.baseMaxHull = Math.max(1, this.baseHull + level * this.hullPerLevel);
this.maxHull = Math.max(1, Math.round(this.baseMaxHull * (1 + this.hullBonus)));
this.maxShields = Math.max(0, this.baseShields + level * this.shieldPerLevel);
this.hull = Math.min(this.hull ?? this.maxHull, this.maxHull);
this.shields = Math.min(this.shields ?? this.maxShields, this.maxShields);
if (this.fullHealOnLevelUp) {
this.hull = this.maxHull;
this.shields = this.maxShields;
}
}
/**
* A level landed (the manager's 'levelup' event — one per level
* crossed): the armor bump (the placeholder toughness — the skill layer
* lands on the same event later), the moment (a cyan burst off the ship
* + the LEVEL callout + the voice), and the scene's callback (the kill
* feed's line — js/scenes/GameScene.js wires it).
* @param {{from:number, to:number, source:string}} ev the event payload
*/
_onLevelUp(ev) {
this._applyArmorToLevel();
if (this.ship) {
this.fx.explode(this.ship.x, this.ship.y, FX_COLORS.respawn);
this.fx.text(
this.ship.x,
this.ship.y - 44,
`LEVEL ${this.xp.level}`,
FX_COLORS.xp,
18,
900,
40,
);
}
combatSfx(this.scene, 'levelup');
if (this.onLevelUp) this.onLevelUp(this.xp.level, ev);
}
// ------------------------------------------------------------ the loop
/**
* Step the combat (call from the scene's update, after the ship).
* @param {number} time game clock (ms — the scene's stepped clock)
* @param {number} delta frame delta (ms)
*/
update(time, delta) {
const dt = Math.min(delta, 64) / 1000;
const player = this.playerDown ? null : this.ship;
// 0) A SKILL CHARGE IN FLIGHT (data/skills.json → dashBlast) drives the hull
// itself and holds the player's steering off until it lands, so the pack
// AI, the collisions and the camera below all see where the ship ACTUALLY
// got to this frame.
this._stepDash(dt);
// 0b) THE WRAITH'S SKILLS (data/skills.json → 'boleadoras' / 'blink'):
// the bola sets in flight (each one's hunt + snag) and the decoys'
// stand (their shimmer + their expiry), both on the combat clock.
this._stepBoleadoras(dt, time);
this._stepDecoys(time);
this._stepFlames(dt, time);
this._stepBurns(time);
// THE ENEMY FACTION HANDS (js/combat/EnemyAbilities.js): the psychic
// tides' arrivals (the shove/pull beats) and the illusions' stands
// (shimmer → expiry), both on the combat clock.
stepTkwaves(this, time, player);
stepIllusions(this, time);
// THE ZENITH SHOCK LAYER (js/combat/EnemyAbilities.js → stepShock):
// the volt darts' fuses (their proximity detonations) and the
// electrified zones' bloom/hold/fade + their DoT ticks on the player.
stepShock(this, time, player, delta);
// 1) The pack: orbit AI + lances (a dead one is its reform clock).
// The ON-SCREEN RULE: the AI may WANT to fire, but the lance only
// leaves the barrel while the craft is (at least partly) on screen —
// an off-screen hostile holds its fire until it flies into view.
for (const e of this.enemies) {
if (e.dead) {
if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time);
continue;
}
// THE FACTION DISPERSE (the war-zones): a faction's ship whose rival
// group is all spent and who bears no heat on the player peels away
// and flies off-screen — the encounter ends on its own (the player
// observed it; the sky has to clear for the director's cadence).
// Ships that DO bear on the player keep the classic orbit instead.
if (e.factionId && e.hostileToPlayer !== true && !this._rivalLive(e.factionId)) {
this._peelOff(e, delta);
continue;
}
// THE SNARE (data/skills.json → 'boleadoras'): a strung hostile is
// DISABLED until its stun clock runs out — no orbit AI, no lances (the
// craft stands on the line where the cords caught it) — and it SHOWS
// it: dimmed to the data's fx.stunAlpha with the crackling discharge
// (Fx.stunArms). The release's edge settles the read (the beat).
if (time < (e.stunUntil || 0)) {
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
this._stunHoldFrame(e, time); // the strung read (idempotent per frame)
continue;
}
if (e._stunDimmed || e._stunArms) this._stunRelease(e, true); // the release beat (once)
// THE TARGET (pickEnemyTarget): the player — or, in a mixed
// encounter, the nearest in-weapon-range escort when the player
// isn't (the escort draws aggro; a pirates-only fight resolves to
// exactly the classic behavior).
const target = this.pickEnemyTarget(e, player);
// THE FACTION SKILLS (the abilities' frame step, BEFORE the fire
// gate — a cloaked ambush that reveals this frame may strike this
// frame; the flank's cut / the blink / the tides all run here).
if (e.abilities && e.abilities.length > 0) stepAbility(this, e, target, time);
const wantsFire = e.update(time, delta, target);
if (wantsFire && target && !e.cloaked && this.craftVisible(e)) this.fireEnemy(e, target, time);
// THE ATTACK COMM (the type's comms.attack — data/combat.json): the
// ON-SCREEN roll (50%) fires once the craft first flies into view —
// unless its arrival roll already commed it (a pirate never
// double-comms; the marks ride the ship).
if (e.comms?.attack && !e._commedAttack) {
const vis = this.craftVisible(e);
if (vis && !e._wasOnScreen) this._commAttackRoll(e, e.comms.attack.onScreenChance);
e._wasOnScreen = vis;
}
}
// 1b) NEUTRAL ESCORTS (js/combat/NeutralShip.js): escort/engage/
// break AI on their own clocks. The on-screen rule gates their
// lances exactly like the pack's (an off-screen escort holds its
// fire until it flies into view), and their target is always a
// pirate — the player is never aimed at (in a war-zone the target
// list is the player's OWN pool — a neutral-to-the-player faction
// never takes escort fire either).
for (const a of this.allies) {
if (a.dead) continue;
const r = a.update(time, delta, player, this._playerPool());
if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) {
this.fireAlly(a, r.target, time);
}
// THE NEUTRAL SCAN COMM (trigger 'neutralScan' — data/comms.json): the
// moment a pilot flies ON SCREEN while the encounter is neutral-only
// (no hostiles anywhere), the pilot's visible scan comm arms (the box
// plays their clip muted with the rolled scan-XX voice). Once per
// encounter (the hub's in-flight guard keeps a barrage away regardless).
const vis = this.craftVisible(a);
if (vis && !a._wasOnScreen) {
a._wasOnScreen = true;
if (!this._neutralScanCommed && this._neutralOnly()) {
this._neutralScanCommed = true;
this._fireNeutralComm('neutralScan');
}
}
// THE SCAN-COMPLETE COMM (trigger 'neutralScanComplete'): the moment a
// pilot BREAKS OFF (escort window lapsed → state 'break', peeling off
// screen) in a neutral-only encounter — the rolled scan-complete-XX
// voice ("scan complete, nothing found"). Once per encounter, on the
// first pilot to break.
if (a.state === 'break' && !this._neutralBreakCommed && this._neutralOnly()) {
this._neutralBreakCommed = true;
this._fireNeutralComm('neutralScanComplete');
}
}
// A broken escort that has flown fully off-screen is gone (the
// encounter ends when the last one is), and a shot-down escort is
// collected (killAlly's mark).
if (this.allies.length > 0) {
for (const a of this.allies) {
if ((a.gone || a._removed) && a.destroy && !a.destroyed) {
this.fx.releaseCraft(a);
a.destroy();
}
}
this.allies = this.allies.filter((a) => !a.gone && !a._removed);
if (this.allies.length === 0) {
this._neutralScanCommed = false; // the roster is spent — a fresh encounter re-arms the comms
this._neutralBreakCommed = false;
}
// The last escort off-screen (with the pack spent) is the encounter
// OVER — the flip back to the normal deck/soundtrack rides the same
// seam as a kill (killEnemy/killAlly already fire it; the break-off
// cull is the ending that was missing it).
this._setActive();
}
// 1c) PEELERS (the war-zones' disengage): a faction ship fully out of
// view after its rivals were spent is collected — the roster
// drops, the director's cadence resumes.
if (this.enemies.some((e) => e && e._peelGone)) {
for (const e of this.enemies) {
if (!e || !e._peelGone) continue;
this.fx.releaseCraft(e); // settle any flash before the craft goes
if (e.destroy && !e.destroyed) e.destroy();
}
this.enemies = this.enemies.filter((e) => !(e && e._peelGone));
this._setActive();
}
// 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own — but
// only at what's on screen (off-screen hostiles can't be fired
// upon; the NEAREST counts only if it's in view).
if (
player &&
this.autoFire.enabled &&
this.autoFire.range > 0 &&
time >= this.nextFireAt
) {
const target = pickTarget(
this._firePool(),
player.x,
player.y,
this.autoFire.range,
(e) => this.craftVisible(e),
);
if (target) this.firePlayerAt(player, target, time);
}
// 3) Tracers: age + cull (no walls here — the arena is the tether range).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || !b.active) {
this.bullets.splice(i, 1);
continue;
}
b.update(delta);
if (b.dead) this.bullets.splice(i, 1);
}
// 3b) The heat-seekers (js/combat/Weapons.js): age the fuses + home on
// the player while it stands (fly true after it falls); the fuses
// end them. No walls here (the range clips its own).
stepMissiles(this, delta, this.playerDown ? null : player);
// 3c) THE MINDWEAVE'S WILL (stepHomingBullets): the listed factions'
// bolts bend toward the nearest live hostile before the
// collisions read this frame's travel.
this.stepHomingBullets(delta);
// 4) Collisions: your tracers × the pack, their lances × you, rams.
this.collide(delta, player);
// 5) The player's armor bookkeeping: shield + energy regen, respawn.
if (!this.playerDown) {
const p = config.section('combat.playerShip', {});
if (this.shields < this.maxShields && this.maxShields > 0) {
const grace = (Number(p.shieldRegenDelay) || 4) * 1000;
const rate = Math.max(0, Number(p.shieldRegenRate) || 14);
if (time - this.lastHitAt >= grace) {
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
}
}
// THE NANOBOTS (data/skills.json → 'hullRegen'): the hull's own mending
// clock — every effect.intervalMs (the data's number, read off the
// ledger) the hull regrows the ledger's share of its MAX (1% per point).
// A full hull banks nothing (the clock stays spent, never queued), and
// a mend tops the hull back toward max and nowhere past it — it is a
// repair, not a level-up.
const regen = this.hullRegen();
if (regen.fraction > 0 && this.hull < this.maxHull - 1e-9) {
this._nanobotAccumMs += dt * 1000;
for (let mends = 0; this._nanobotAccumMs >= regen.intervalMs && mends < 4; mends += 1) {
this._nanobotAccumMs -= regen.intervalMs;
this.hull = Math.min(this.maxHull, this.hull + regen.fraction * this.maxHull);
const col = hexToInt(regen.color, 0x7ce8a4);
if (this.ship) {
this.fx.flash(this.ship.x, this.ship.y, col, 26, 150);
this.fx.sparks(this.ship.x, this.ship.y, col, 5, 150, 300);
}
}
} else {
this._nanobotAccumMs = 0; // a full (or unranked) hull banks no mends
}
if (this.energy < this.maxEnergy) {
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
}
} else if (this.playerRespawn && this.respawnAt > 0 && time >= this.respawnAt) {
// (the recharges below run even while the hull is down — a dead ship
// waiting out a 6 second cooldown is its own punishment)
this.revivePlayer(time);
}
// 5b) THE SKILL RECHARGES — every skill's own clock (data/skills.json →
// cooldown), independent of energy (which regenerates above) and of
// whether the ship is up.
this._tickSkillCooldowns(dt);
this.fx.update(delta);
// 6) The WAVE RUN (Waves.js): lead/gap clocks, the cleared handoff.
this._waveTick(time);
}
/** The nearest live targetable hostile to (x, y), or null (the shared pickTarget rule). */
nearestEnemy(x, y) {
return pickTarget(this._playerPool(), x, y);
}
/**
* The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js):
* the hostile the auto-fire is on — the nearest VISIBLE one WITHIN
* auto-fire range (the same pickTarget rule, ranged by game.autoFire.range
* and narrowed by the on-screen rule — so the brackets sit on exactly the
* ship the cannon can engage, never an off-screen one).
*/
currentTarget() {
if (!this.ship) return null;
return pickTarget(
this._firePool(),
this.ship.x,
this.ship.y,
this.autoFire.range,
(e) => this.craftVisible(e),
);
}
/**
* Can the PLAYER'S guns / skills engage this hostile? Everything may,
* EXCEPT a FACTION ENCOUNTER ship whose own faction stands neutral to
* the player (the war-zone disposition — data/combat.json →
* encounters.faction.hostileBelow; the spawner stamps `factionId` +
* `hostileToPlayer` on the ship): the player observes that side of the
* fight — the auto-fire's pool, the target lock, and the skills'
* nearest/radius picks all run through this.
* @param {object|null} e
* @returns {boolean}
*/
playerTargetable(e) {
return !!e && !(e.factionId && e.hostileToPlayer !== true);
}
/** The pack narrowed to what the player may engage (see playerTargetable). */
_playerPool() {
return this.enemies.filter((e) => this.playerTargetable(e));
}
/**
* THE CANNON'S TARGET POOL — the live hostiles the player may engage
* (the war-zone disposition filters, see playerTargetable) PLUS the
* live MINDWEAVE ILLUSIONS (js/combat/EnemyAbilities.js): a phantom
* that stands in the pool is what the auto-fire engages (the phantoms
* absorb the tracers — collide pops them; the real hull stands
* elsewhere). In a war-zone where EVERY hostile is neutral to the
* player the pool stays EMPTY (a phantom of a faction you're not
* fighting is not something you shoot at either).
* @returns {Array<object>} the pool (pickTarget skips the dead)
*/
_firePool() {
const own = this._playerPool();
if (own.length === 0) return [];
return [...own, ...(Array.isArray(this.illusions) ? this.illusions : [])];
}
/**
* The PLAYER'S HEADING (the abilities' geometry seam — "behind the
* player" is the player's rear arc): the ship's heading is its render
* rotation minus the art offset (js/entities/Ship.js's convention),
* 0 when the ship is down / a stub scene.
* @returns {number} radians
*/
playerFacing() {
const s = this.ship;
if (!s) return 0;
if (typeof s._heading === 'function') return s._heading();
return (Number(s.rotation) || 0) - (Number(s.artOffset) || 0);
}
/**
* Teleport a craft (the cloak's phase / the weaver's blink) — the hop
* is a position swap with no momentum (velocity zeroed) so the chase
* AI streams out of the new stand.
*/
teleportCraft(e, x, y) {
e.x = x;
e.y = y;
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
}
/**
* A pirate's target this frame (the mixed-encounter rule): the
* PLAYER while they're inside the pirate's weapon range (a
* pirates-only fight — or the player in range — resolves exactly to
* the classic chase); else the NEAREST live escort within that range
* (a neutral ship draws aggro when the pirate can't reach the
* player); else the player (the classic orbit — the fire gate keeps
* it honest until in range).
*
* FACTION ENCOUNTERS (the war-zones): a tagged ship (`e.factionId`)
* ALWAYS fights the OTHER faction present — the nearest live rival-faction
* craft in weapon range, chasing it when out of range — and the player
* only when the ship's disposition is hostile (`e.hostileToPlayer`,
* stamped by the spawner off the player's standing with the faction —
* data/combat.json → encounters.faction.hostileBelow). With no rival up
* and no heat on the player the ship stands targetless (the caller's
* fire gate holds its lances) and the update loop peels it off (the
* sky has to clear for the director's cadence).
* @returns {object|null} the craft to orbit + fire at (null = player down)
*/
pickEnemyTarget(e, player) {
if (!player && !e.factionId) return null;
// THE WRAITH'S DECOY (data/skills.json → 'blink') carries ALL the aggro
// while it stands: the nearest live decoy is what every pirate orbits
// and lances (bolts that reach it fizzle — collide's decoy pass), and
// the hull that actually owns the run is never aimed at.
let decoy = null;
let decoyD = Infinity;
for (const d of this.decoys) {
if (d.dead) continue;
const d2 = (d.sprite.x - e.x) ** 2 + (d.sprite.y - e.y) ** 2;
if (d2 < decoyD) { decoyD = d2; decoy = d; }
}
if (decoy) return decoy.sprite;
// THE FACTION WAR (see above): rivals first, the player only when
// the disposition is hostile.
if (e.factionId) {
const range = Number((e.wpn && e.wpn.range) || 0);
const inRange = (o) =>
range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true;
let inBest = null;
let inBestD = Infinity;
let outBest = null;
let outBestD = Infinity;
const consider = (o) => {
const d2 = (o.x - e.x) ** 2 + (o.y - e.y) ** 2;
if (inRange(o)) {
if (d2 < inBestD) { inBestD = d2; inBest = o; }
} else if (d2 < outBestD) {
outBestD = d2; outBest = o;
}
};
for (const s of this.enemies) {
if (s === e || s.dead || s.gone || s.cloaked) continue;
if (!s.factionId || s.factionId === e.factionId) continue;
consider(s);
}
if (e.hostileToPlayer === true && player) consider(player);
if (inBest) return inBest;
if (outBest) return outBest; // the rival is out of range — chase it
// Nothing to chase and no heat on the player: targetless (the
// caller's fire gate holds the lances; the update loop peels the
// ship off once its rivals are spent).
return null;
}
if (!player) return null;
const range = Number((e.wpn && e.wpn.range) || 0);
const inRange = (o) =>
range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true;
if (inRange(player)) return player;
let best = null;
let bestD = Infinity;
for (const a of this.allies) {
if (a.dead || !inRange(a)) continue;
const d2 = (a.x - e.x) ** 2 + (a.y - e.y) ** 2;
if (d2 < bestD) { bestD = d2; best = a; }
}
return best ?? player;
}
/**
* A live enemy of ANOTHER faction (the war-zones' cross-faction fight —
* the peeling gate: when the last one is spent, a ship that doesn't
* bear on the player peels off).
* @param {string} factionId
* @returns {boolean}
*/
_rivalLive(factionId) {
return this.enemies.some(
(s) =>
!!s &&
!!s.factionId &&
s.factionId !== factionId &&
!s.dead &&
!s.gone &&
!s._peelGone
);
}
/**
* PEEL OFF (the war-zones' disengage): a faction ship whose rival group
* is spent and who bears no heat on the player steers AWAY from the
* player at full speed and, once fully off the camera's visible rect,
* is gone (the update loop collects it) — the encounter's live count
* drops and the director's cadence resumes (the player only observed;
* no kill read — the craft isn't a bounty). Mirrors NeutralShip's
* 'break' state (the player's own escorts peel the same way).
* @param {object} e the peeling hostile
* @param {number} delta frame delta (ms)
*/
_peelOff(e, delta) {
e.peeling = true;
if (e.gone) return; // already off-screen — the cull collects it
const dt = Math.min(delta, 64) / 1000;
const px = this.ship ? this.ship.x : (e.lastPx ?? e.x);
const py = this.ship ? this.ship.y : (e.lastPy ?? e.y);
const dx = e.x - px;
const dy = e.y - py;
const d = Math.hypot(dx, dy) || 1;
const dirx = dx / d;
const diry = dy / d;
const sp = Math.max(240, (Number(e.speed) || 0) * 1.6);
const k = 1 - Math.exp(-2.5 * dt);
const body = e.body;
if (body) {
body.velocity.x += (dirx * sp - body.velocity.x) * k;
body.velocity.y += (diry * sp - body.velocity.y) * k;
}
const want = body && body.velocity.length() > 4
? Math.atan2(body.velocity.y, body.velocity.x)
: Math.atan2(diry, dirx);
e.rotateToward(want + (e.artOffset || 0), dt);
const view = this.visibleView();
if (view && !inView(e.x, e.y, Math.max(e.hitRadius || 0, (e.size || 0) / 2), view)) {
e.gone = true; // out of the fight — the `active` gate and the cull take it from here
e.setVisible(false);
if (body) {
body.velocity.x = 0;
body.velocity.y = 0;
}
e._peelGone = true;
this._setActive();
}
}
/**
* RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's
* start position settles AFTER create() (the tether barrier / a save
* restore places it at the starting system's rim; the fallback spawn
* is the origin), so a pack spawned around the create-time position
* would sit kilometers off. Shifts every live enemy so the pack's
* centroid lands on the ship (each enemy's ring angle is preserved).
* A no-op once anchored (within 64px), with no ship, or with no live
* enemies. The range doesn't need it (its ship never settles away)
* — but the shared method is where the rule lives.
* @returns {boolean} true if the pack moved
*/
resyncAroundShip() {
if (!this.ship) return false;
let cx = 0, cy = 0, n = 0;
for (const e of this.enemies) {
if (!e.dead) { cx += e.x; cy += e.y; n++; }
}
if (n === 0) return false;
cx /= n; cy /= n;
const dx = this.ship.x - cx;
const dy = this.ship.y - cy;
if (Math.hypot(dx, dy) < 64) return false; // already anchored
for (const e of this.enemies) {
if (e.dead) continue;
e.x += dx;
e.y += dy;
}
return true;
}
// --------------------------------------------------------------- firing
/**
* The ship's auto-fire at a hostile (the sandbox's manual cannon, minus
* the key): one tracer from the nose, toward the target, spread-jittered.
* The cooldown lives here (nextFireAt) — the weapon's numbers are
* combat.player.weapon (shared with the range).
*/
firePlayerAt(player, target, time) {
const wpn = config.section('combat.player.weapon', {});
// The cooldown is the WEAPON's beat and the class does not touch it: the
// Vanguard's extra barrel doubles the metal per volley, not the rate of fire.
this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
const nose = (Number(player.radius) || 12) + 8;
const speed = Number(wpn.bulletSpeed) || 600;
// THE SHOT'S LINE — the Wraith's ADVANCED TARGETING COMPUTER leads a moving
// hostile (js/combat/Aiming.js solves where it WILL be when the bolt lands,
// capped by perks.targetingComputer.maxLead so an unreachable intercept falls
// back to raw aim). No class / no computer → the nose stays on the ship.
const computer = this.classState?.hasPerk?.('targetingComputer') ? perkDef('targetingComputer') : null;
let line;
if (computer) {
const hit = intercept(
player.x,
player.y,
target.x,
target.y,
target.body?.velocity?.x ?? 0,
target.body?.velocity?.y ?? 0,
speed,
Number(computer.maxLead) || 0
);
line = hit.angle;
} else {
line = Math.atan2(target.y - player.y, target.x - player.x);
}
// THE BARRELS — the Vanguard's DUAL CANNON fires its twin cannons as one:
// two muzzles symmetric about the firing line, EACH carrying the weapon's
// full damage (that is the class's point: roughly double the shot, same beat).
// The spread jitter lands AFTER the lead solve, per barrel, so the computer's
// answer is not smeared away.
const dual = this.classState?.hasPerk?.('dualCannon') ? perkDef('dualCannon') : null;
const spread = Math.max(0, Number(wpn.spread) || 0);
const muzzles = barrelLine(line, dual ? Number(dual.barrels) || 2 : 1, dual ? Number(dual.barrelOffset) || 9 : 0, nose);
for (const m of muzzles) {
const a = m.angle + (Math.random() * 2 - 1) * spread;
const x = player.x + m.x;
const y = player.y + m.y;
this.spawnBullet(x, y, a, wpn, 'player');
this.fx.muzzle(x, y, a, wpn.color ?? '#00e5ff', Number(wpn.muzzle) || 46);
}
combatSfx(this.scene, 'cannon_fire'); // one mass-cannon voice per VOLLEY (the enemy lances keep the blaster ping)
}
/**
* A hostile's weapon volley — the SHARED pattern dispatch (js/combat/Weapons.js)
* both combat contexts run: the base 'lance' plus the new types' patterns
* (spray / burst / rapid / ring / missile / combo — data/combat.json →
* enemyTypes, weapon.pattern).
*/
fireEnemy(e, player, time) {
// The WAR-ZONE disposition (e.hostileToPlayer, see the spawner) rides
// the volley — the bolts/seekers it spawns carry it, so a
// neutral-to-the-player faction's fire fizzles past the ship (the
// collide passes + the missile's home rule) — and the shooter's
// FACTION rides them too (the crossfire passes: a faction's fire
// hits the rival faction's craft, never its own wing).
return firePattern(this, e, player, {
...(e.wpn ?? {}),
hostileToPlayer: e.hostileToPlayer !== false,
factionId: typeof e.factionId === 'string' ? e.factionId : null,
});
}
/**
* A neutral escort's volley — the same shared pattern dispatch
* (js/combat/Weapons.js) as the hostiles, except the bolts/seekers
* carry the 'ally' owner: they hit the pack (the kill still banks the
* pirate's XP through killEnemy), never the player (the collide
* passes + Weapons.missilesHitPlayer skip them), and the player's
* auto-fire never targets the escort (a different roster).
*/
fireAlly(a, target, time) {
return firePattern(this, a, target, { ...(a.wpn ?? {}), owner: 'ally' });
}
/**
* The re-home seam for escort seekers (Weapons.stepMissiles): the
* nearest live pirate to the missile, or null (fly straight — the
* fuse ends it).
*/
allyMissileTarget(m) {
return pickTarget(this._playerPool(), m.x, m.y);
}
spawnBullet(x, y, angle, wpn, owner) {
const b = new Bullet(this.scene, {
x,
y,
angle,
speed: Number(wpn.bulletSpeed) || 600,
damage: Number(wpn.damage) || 8,
life: Number(wpn.bulletLife) || 1.2,
color: wpn.color ?? '#ffffff',
size: Number(wpn.size) || 16,
boltLength: Number(wpn.boltLength) || 0, // 0 = Bullet's default (size × 3)
boltWidth: Number(wpn.boltWidth) || 0,
owner,
});
// The WAR-ZONE disposition rides the bolt (the shooter's stamp —
// fireEnemy merges it onto the weapon): an 'enemy' bolt from a
// neutral-to-the-player faction fizzles past the ship (collide's
// lances×player pass).
b.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true;
// The shooter's FACTION (the crossfire passes' friendly-fire rule —
// a faction's bolt drops the rival faction's hulls, never its own
// wing; a classic bolt has none and never shoots itself).
b.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null;
// THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): a listed
// faction's bolt/shell bends toward the nearest live hostile for the
// first `durationMs` of flight — the weaver's threads find their mark
// without negating a committed dodge (the bend is turn-rate capped).
const bh = this.bulletHoming;
if (owner === 'enemy' && b.factionId && Array.isArray(bh.factions) && bh.factions.includes(b.factionId)) {
b.homing = {
turn: Math.max(0, Number(bh.turnRate) || 0),
left: Math.max(0, Number(bh.durationMs) || 0),
range: Math.max(40, Number(bh.range) || 0),
};
}
b.setDepth(20);
this.bullets.push(b);
return b;
}
/**
* THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): the first
* `durationMs` of flight, a listed faction's bolts and shells bend
* toward the NEAREST LIVE HOSTILE within range — the threads find their
* mark, subtle yet effective (a capped turn-rate: a hard dodge still
* wins, a straight shot no longer misses). Hostiles = the rival
* faction's wing (never the shooter's own — no friendly homing) and,
* for bolts hostile to the player, the player itself. War-zone fire
* (hostileToPlayer false) never homes on the player — the will only
* curves the fire at the rival wing.
* @param {number} delta frame delta (ms)
*/
stepHomingBullets(delta) {
const bs = this.bullets;
if (!bs || bs.length === 0) return;
const dt = Math.min(delta, 64) / 1000;
const player = this.playerDown ? null : this.ship;
for (let i = bs.length - 1; i >= 0; i--) {
const b = bs[i];
const h = b.homing;
if (b.dead || !h || h.left <= 0) continue;
h.left -= delta;
if (h.left <= 0 || h.turn <= 0) continue;
let bx = 0, by = 0, bd = Infinity;
for (const e of this.enemies) {
if (e.dead || e.cloaked) continue;
if (!e.factionId || e.factionId === b.factionId) continue; // the own wing stands
const dx = e.x - b.x;
const dy = e.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; bx = e.x; by = e.y; }
}
if (player && b.hostileToPlayer !== false) {
const dx = player.x - b.x;
const dy = player.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; bx = player.x; by = player.y; }
}
if (bd === Infinity || bd > h.range * h.range) continue;
if (!b.body) continue;
const vx = b.body.velocity.x;
const vy = b.body.velocity.y;
const sp = Math.hypot(vx, vy);
if (sp <= 0) continue;
const cur = Math.atan2(vy, vx);
const want = Math.atan2(by - b.y, bx - b.x);
let dA = Phaser.Math.Angle.Wrap(want - cur);
const maxTurn = h.turn * dt;
dA = Math.max(-maxTurn, Math.min(maxTurn, dA));
const na = cur + dA;
b.body.velocity.x = Math.cos(na) * sp;
b.body.velocity.y = Math.sin(na) * sp;
b.angle0 = na;
b.rotation = na;
if (typeof b._syncCore === 'function') b._syncCore();
}
}
// ------------------------------------------------------------- collisions
/**
* Swept hit check (the sandbox's anti-tunneling rule): tracers run
* ~700 px/s — at 30 fps that's ~23 px/frame, past a ~25 px combined
* radius, so a point check can tunnel. Sample the segment the bullet
* flew this frame (end, middle, start) against the target circle.
*/
hitsSwept(b, other, otherRadius, dtMs) {
const r = b.radius + otherRadius;
const r2 = r * r;
const vx = b.body ? b.body.velocity.x : 0;
const vy = b.body ? b.body.velocity.y : 0;
const dt = Math.min(dtMs, 64) / 1000;
for (let f = 1; f >= 0; f -= 0.5) {
const dx = other.x - (b.x - vx * dt * f);
const dy = other.y - (b.y - vy * dt * f);
if (dx * dx + dy * dy < r2) return true;
}
return false;
}
/** The collision pass: your tracers × the pack, their lances × you, rams. */
collide(delta, player) {
// THE MINDWEAVE'S PHANTOMS (js/combat/EnemyAbilities.js) — tracers ×
// the illusions FIRST: a bolt that reaches a phantom pops it (the
// hologram has no hull to break) and is spent — the real hull stands
// elsewhere (the blink's point). The ALL-HOSTILE rule: your tracers
// pop any court, and a rival wing's tracers pop THEIR court (the
// bait is built around every enemy, not just the ship) — the own
// wing's bolts pass through their own phantoms (no friendly fire
// on a hologram), and a classic (unclaimed) court only your tracers
// can pop (the old behavior).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead) continue;
if (b.owner !== 'player' && !(b.owner === 'enemy' && b.factionId)) continue;
for (const il of this.illusions) {
if (il.dead) continue;
if (b.owner === 'enemy' && (!il.factionId || il.factionId === b.factionId)) continue;
if (this.hitsSwept(b, il, il.hitRadius, delta)) {
this.fx.flash(il.x, il.y, 0xffffff, 30, 150);
this.fx.sparks(il.x, il.y, 0x9d4dff, 7, 220, 420);
il.dead = true;
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// Your tracers × hostiles (the war-zone disposition: a neutral-to-
// the-player faction's craft is untargetable — the pool's rule).
if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'player') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.playerTargetable(e)) continue; // observe-only: the ship stands
if (e.cloaked) continue; // the cloak holds: unhittable while it phases
if (!this.craftVisible(e)) continue; // off-screen: can't be hit
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
// THE IMPACT READ (js/combat/Fx.js → enemyHit): core flash,
// sparks along the bolt's travel, the craft's white flash,
// and the layer's cue (a cyan shield arc / orange shards).
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfx(this.scene, 'cannon_hit'); // the mass-cannon hit voice (the shield/hull cues ride on)
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
}
// Your escorts' tracers × the pack (the neutral ships' patterns —
// the same hit read as your own; the kill still banks the pirate's
// XP through the one grant). In a war-zone the pack is narrowed to
// the player's own pool — a neutral-to-the-player faction never
// takes escort fire (see playerTargetable).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'ally') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.playerTargetable(e)) continue;
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// Their lances × the RIVAL FACTION's craft (the war-zones' crossfire
// — a faction's bolt drops a hostile-faction hull, exactly the way a
// pirate's can drop a neutral escort in the mixed fight). The bolt
// carries its shooter's faction (fireEnemy's stamp — spawnBullet): a
// faction's fire never touches its own wing (no friendly fire), and a
// classic pack (no faction stamp) never shoots itself. The kill is
// SILENT (killEnemy's npc flag — no XP, no defeat line, no shake: the
// bounty is the player's, the war isn't), and the downed side's
// survivors peel off (the update loop's gate).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
if (!b.factionId) continue; // a classic bolt: the pack never shoots itself
for (const e of this.enemies) {
if (e.dead) continue;
if (!e.factionId || e.factionId === b.factionId) continue; // own wing stands
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now(), true);
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// Their lances × the WRAITH'S DECOYS (data/skills.json → 'blink'): the
// decoy is what drew the fire — a bolt that reaches it fizzles on the
// shell (the hologram has no hull to break, only the pop).
if (this.decoys.length > 0) {
for (const d of this.decoys) {
if (d.dead) continue;
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
if (this.hitsSwept(b, d.sprite, d.hitRadius, delta)) {
this.fx.flash(d.sprite.x, d.sprite.y, 0xffffff, 30, 150);
this.fx.sparks(d.sprite.x, d.sprite.y, hexToInt(d.fx.decoyColor, 0xb489ff), 7, 220, 420);
if (b.consume()) this.bullets.splice(i, 1);
}
}
}
}
// Their lances × you (the war-zone disposition: a neutral-to-the-
// player faction's bolts fizzle by — they never aimed at you, and the
// stray ones pass through the ship).
if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
if (b.hostileToPlayer === false) continue;
if (this.hitsSwept(b, player, Number(player.radius) || 12, delta)) {
// The shield was struck from the laser's incoming bearing (the
// side facing its source — the travel's opposite).
this.damagePlayer(b.damage, { x: b.x, y: b.y, bearing: b.angle0 + Math.PI });
if (b.consume()) this.bullets.splice(i, 1);
}
}
}
// Their lances × your escorts (mixed fight: pirate bolts can drop
// the neutral ships — the escort draws fire off the player).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
for (const a of this.allies) {
if (a.dead || !this.craftVisible(a)) continue;
if (this.hitsSwept(b, a, a.hitRadius, delta)) {
const preShields = a.shields;
const killed = a.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(a, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killAlly(a, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// 2b) Heat-seekers × you (js/combat/Weapons.js): the missile's damage
// lands from its incoming bearing; the hit blooms + consumes it.
// Escort seekers are excluded inside (no friendly fire).
if (player) missilesHitPlayer(this, player, Number(player.radius) || 12, delta);
// Their heat-seekers × your escorts (mixed fight: a pirate seeker
// can drop a neutral ship).
this.missilesHitAllies(delta);
// FACTION SEEKERS × the rival faction's craft (the war-zones'
// crossfire — a faction's seeker drops a hostile-faction hull).
this.missilesHitRivals(delta);
// Your escorts' heat-seekers × the pack (the escorts' missile
// patterns — one kill path, the XP still lands).
this.missilesHitEnemies(delta);
// Rams (you × the pack) — push apart, bounce, damage both (grace-limited).
// THE CHARGE PASSES THROUGH: while a dash is in flight the hull is not
// bounced and trades no ram damage on the way (data/skills.json →
// effect.overshootPx drives it out the far side) — the ARRIVAL BLAST is the
// settlement for the fly-by, so a single skill never lands two hits.
if (player && !this.dash) {
for (const e of this.enemies) {
// A neutral-to-the-player faction's craft never rams the ship —
// the bounce read is a hostile's, and the player only observes.
if (!e.dead && this.playerTargetable(e)) this.ramPair(player, e);
}
}
}
/**
* Hostile seekers × live escorts (a mixed fight: a pirate seeker can
* drop a neutral ship) — the hit is real damage, the kill is
* killAlly (no XP — the bounty is the pirate's; no comms — the
* neutrals don't talk).
*/
missilesHitAllies(delta) {
const ms = this.missiles;
if (!ms || ms.length === 0 || this.allies.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) { ms.splice(i, 1); continue; }
if (m.owner === 'ally') continue;
if (m.hostileToPlayer === false) continue; // the neutral faction's seeker: no player threat
for (const a of this.allies) {
if (a.dead || !this.craftVisible(a)) continue;
if (this.hitsSwept(m, a, a.hitRadius, delta)) {
const preShields = a.shields;
const killed = a.damage(m.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(a, m.x, m.y, m.heading, kind, m.damage);
this.fx.damage(m.x, m.y, m.damage, kind);
combatSfxEnemyHit(this.scene, preShields, m.damage);
if (killed) this.killAlly(a, this._now());
if (m.consume()) ms.splice(i, 1);
break;
}
}
}
}
/**
* FACTION SEEKERS × the RIVAL FACTION's craft (the war-zones' crossfire
* — a faction's seeker drops a hostile-faction hull, exactly the way a
* pirate's can drop a neutral escort in the mixed fight; the seeker
* carries its shooter's faction — launchMissiles' stamp — so a
* faction's seekers never take their own wing, and a classic seeker
* (no faction) never shoots the pack). The kill is SILENT (killEnemy's
* npc flag — no XP, no defeat line: the bounty is the player's, the
* war isn't).
*/
missilesHitRivals(delta) {
const ms = this.missiles;
if (!ms || ms.length === 0 || this.enemies.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) { ms.splice(i, 1); continue; }
if (m.owner !== 'enemy') continue;
if (!m.factionId) continue; // a classic seeker: the pack never shoots itself
for (const e of this.enemies) {
if (e.dead) continue;
if (!e.factionId || e.factionId === m.factionId) continue; // own wing stands
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(m, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(m.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage);
this.fx.damage(m.x, m.y, m.damage, kind);
combatSfxEnemyHit(this.scene, preShields, m.damage);
if (killed) this.killEnemy(e, this._now(), true);
if (m.consume()) ms.splice(i, 1);
break;
}
}
}
}
/** Escort seekers × the pack — the same hit read as the player's. */
missilesHitEnemies(delta) {
const ms = this.missiles;
if (!ms || ms.length === 0 || this.enemies.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) { ms.splice(i, 1); continue; }
if (m.owner !== 'ally') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.playerTargetable(e)) continue; // the neutral faction stands (see playerTargetable)
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(m, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(m.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage);
this.fx.damage(m.x, m.y, m.damage, kind);
combatSfxEnemyHit(this.scene, preShields, m.damage);
if (killed) this.killEnemy(e, this._now());
if (m.consume()) ms.splice(i, 1);
break;
}
}
}
}
/** The scene's clock (ms) — used for the kill bookkeeping's timestamps. */
_now() {
return this.scene.time?.now ?? Date.now();
}
/**
* A ship-ship collision: push apart, exchange normal velocity (bounce),
* and deal ram damage to BOTH (grace-limited — the hostile's own type
* config: combat.enemy.ram* or the type's override).
*/
ramPair(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const gap = (Number(a.radius) || 12) + b.hitRadius;
if (d >= gap || d <= 0) return;
const nx = dx / d;
const ny = dy / d;
const push = (gap - d) / 2;
a.x -= nx * push;
a.y -= ny * push;
b.x += nx * push;
b.y += ny * push;
if (a.body && b.body) {
const avx = a.body.velocity.x;
const avy = a.body.velocity.y;
const rvx = b.body.velocity.x - avx;
const rvy = b.body.velocity.y - avy;
const vn = rvx * nx + rvy * ny;
if (vn < 0) {
const j = (-(1 + this.restitution) * vn) / 2; // equal-mass impulse
a.body.velocity.x = avx - j * nx;
a.body.velocity.y = avy - j * ny;
b.body.velocity.x += j * nx;
b.body.velocity.y += j * ny;
}
}
const t = this._now();
if (t >= this.ramUntil) {
this.ramUntil = t + (b.ramGraceMs ?? 800);
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const bearing = Math.atan2(b.y - a.y, b.x - a.x); // the side that faced the rammer
this.fx.flash(midX, midY, 0xffffff, 46, 160);
this.fx.sparks(midX, midY, FX_COLORS.hull, 8, 260, 520, bearing, 0.7);
this.fx.sparks(midX, midY, 0xffffff, 4, 200, 380, bearing, 0.6);
this.fx.craftFlash(b);
const preShields = b.shields;
const ramSelf = b.ramDamageSelf ?? 0;
this.damagePlayer(b.ramDamage ?? 0, { bearing }); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this.scene, preShields, ramSelf);
const killed = b.damage(ramSelf);
if (killed) this.killEnemy(b, t);
}
}
/**
* A neutral escort is down (a pirate's bolt/seeker): the kill read
* and the pull-out. No XP (a lost escort is not a bounty — the bank
* is the pirate's), no comms (the neutrals don't talk), no respawn
* (the encounter's force is spent).
*/
killAlly(a, time) {
if (a._killFx) return;
a._killFx = true;
this.fx.releaseCraft(a);
this.fx.explode(a.x, a.y, '#37e0a1');
combatSfx(this.scene, 'kill');
a.setVisible(false);
if (a.body) {
a.body.velocity.x = 0;
a.body.velocity.y = 0;
}
a._removed = true; // the update() cull collects it
this._setActive();
}
// ----------------------------------------------------- damage / lifecycle
/**
* Hit the player's armor (shields → hull). Kills when the hull breaks.
* `o.bearing` (rad from the ship toward the threat — the laser's
* incoming side, or the ram's heading) steers the shield read;
* omitted (a bare call) defaults to straight up.
*/
damagePlayer(n, o = {}) {
if (this.playerDown) return;
this.lastHitAt = this._now();
const armor = { hull: this.hull, shields: this.shields };
const preShields = this.shields;
const { dead, shieldHit, hullHit } = applyDamage(armor, n);
this.hull = armor.hull;
this.shields = armor.shields;
const broke = preShields > 0 && this.shields <= 0;
const kind = shieldHit > 0 ? 'shield' : 'hull';
const bearing = Number.isFinite(o.bearing) ? o.bearing : -Math.PI / 2;
if (this.ship) {
if (shieldHit > 0) {
// THE SHIELD READ — the sphere struck from the bearing, the hit
// region highlighted (js/combat/Fx.js → shieldImpact).
this.fx.shieldImpact(this.ship, bearing, shieldHit);
}
if (broke) this.fx.shieldBreak(this.ship); // the collapse cue
if (hullHit > 0) {
// The armor taking it: a hot bloom + the craft's white flash.
this.fx.flash(this.ship.x, this.ship.y, FX_COLORS.hull, 60, 140);
this.fx.craftFlash(this.ship);
}
if (shieldHit > 0 || hullHit > 0) {
this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts)
}
}
// The player-hit voices (one place: lances AND rams funnel here).
combatSfx(this.scene, 'impact');
if (shieldHit > 0) combatSfx(this.scene, 'shield');
if (hullHit > 0) combatSfx(this.scene, 'hull');
// The world kick (the UI pass stays still — js/combat/CamShake.js).
combatShake(this.scene, broke ? 'break' : 'hit');
if (this.onPlayerDamage)
this.onPlayerDamage(n, { hull: this.hull, shields: this.shields, shieldHit, hullHit });
if (dead) this.killPlayer();
}
/** The player's hull broke: down (and reformed on the clock, if armed). */
killPlayer() {
this.playerDown = true;
this.hull = 0;
if (this.ship) {
this.fx.releaseCraft(this.ship); // out in normal colors, never mid-flash
this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold)
this.ship.stop();
this.ship.setVisible(false);
}
combatSfx(this.scene, 'down');
if (this.onPlayerDown) this.onPlayerDown();
if (this.playerRespawn) {
this.respawnAt = this._now() + this.playerRespawnDelay;
}
}
/**
* The dock's REPAIR — a WORLD LANDING (planet/station) puts the ship in
* the bay: hull + shields + energy back to max (GameScene.startLanding
* calls it; the system-arrival repair is the same settle via the
* constructor's o.vitals). The respawn's full-heal beat stands on its
* own.
*/
repairFull() {
this.hull = this.maxHull;
this.shields = this.maxShields;
this.energy = this.maxEnergy;
this.lastHitAt = -1e9; // the shield-regen grace clock stands fresh
}
/** The clock ran out — the player is back, full armor, in the same seat. */
revivePlayer(time) {
this.playerDown = false;
this.hull = this.maxHull;
this.shields = this.maxShields;
this.lastHitAt = 0;
this.respawnAt = 0;
if (this.ship) {
settleCraftTint(this.ship); // never reappear holding a white hit-flash
this.ship.setVisible(true);
}
if (this.ship) this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue
combatSfx(this.scene, 'respawn');
if (this.onPlayerUp) this.onPlayerUp();
}
/**
* A hostile went down: the kill FX + bookkeeping. WAVE SHIPS get spent
* (their kill feeds the runner's rolling replacements — no respawn
* clock); the rest keep their type's reform clock (combat.enemy.respawn
* / the type's override). Idempotent via _killFx — damage() flips
* e.dead first, so the dead flag can't be the "already counted" guard.
*/
killEnemy(e, time, npc = false) {
if (e._killFx) return;
e._killFx = true;
// Settle any pending white hit-flash NOW — the craft dies in its
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
// AND settle any strung read (the kill supersedes the stun's dim and
// discharge — no dimmed corpse, no orphaned bolts).
this._stunRelease(e, false);
// THE NPC KILL (the war-zones' crossfire — a faction dropped the
// craft, not the player): the read stands (the wreck, the beat),
// but the kill's BOUNTY doesn't — no XP, no kill count, no defeat
// line, no screen kick (the war is the world's, the player's ledger
// is the player's).
if (!npc) this.kills += 1;
// COMBAT XP — the kill's reward (the hostile's own type's value —
// combat.enemy.xp / the type's override; the curve is
// data/progression.json). The +N floater lands on the wreckage, and a
// crossed level's beat rides the manager's 'levelup' event (the armor
// bump, the burst, the feed's line) — the kill path stays one grant.
if (!npc && this.xp) {
const gained = this.xp.grant(Number(e.xp) || 0, 'kill');
if (gained.gained > 0) this.fx.xp(e.x, e.y, gained.gained);
}
this.fx.explode(e.x, e.y, '#ff5d6f');
if (!npc) combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
combatSfx(this.scene, 'kill');
// THE DEFEAT COMM (data/combat.json → comms.defeat): a downed pirate
// rolls its defeat line (20%) — the comm's beat rides the same kill
// (once per death — killEnemy is idempotent via _killFx).
if (!npc) this._commDefeatRoll(e);
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
e.body.velocity.y = 0;
}
const run = this.waveRun;
const inWave = e.wave === true && run && run.state === 'live';
const respawn = !inWave && e.respawn !== false;
e._respawnPending = respawn;
e.respawnAt = respawn ? time + (e.respawnDelayMs ?? 0) : 0;
if (inWave) {
// The rolling replacement (a fixed wave sends nothing in).
for (const t of run.runner.onKill()) this._spawnWaveEnemy(t);
}
if (this.onKill) this.onKill(e);
// THE FACTION DUEL's end (js/combat/DuelPicker.js re-shows): the
// marked fighter is down — the arena offers the next choice.
if (e.duel && this.duel) this.duel.active = false;
this._setActive();
}
// ------------------------------------------------------ the faction duel
/**
* Open the FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave
* boots): no wave run — the picker (js/combat/DuelPicker.js) offers the
* faction's fighters (data/combat.json → combat.factions) one at a
* time; spawnDuel / spawnDuelPack materialize the chosen fight.
* @param {string} factionId a combat.factions id ('apex' | 'mindweave' | 'zenith')
* @returns {object} the duel state ({ faction, active })
*/
startDuel(factionId) {
this.cancelWaves(true); // the duel arena is its own fight
this.duel = { faction: factionId, active: false };
return this.duel;
}
/**
* One DUEL FIGHTER (the picker's pick): a single hostile of `typeId`,
* no respawn (the duel ends when it goes down), streamed in from a
* nearer-than-usual bearing (a duel should start within moments, not
* after a long fly-in). Replaces any live duel ship (a rematch is a
* fresh craft). Cloaked-ambush fighters phase to their stand on first
* contact (their ability runs from the pack loop — the spawn just
* puts them in range of the player's screen).
* @param {string} typeId a combat.enemyTypes id (the faction's fighter)
* @returns {EnemyShip} the marked fighter
*/
spawnDuel(typeId) {
this._clearDuelShips();
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU;
const r = Math.min(this._offscreenRadius(), 520) * (0.7 + Math.random() * 0.2);
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.duel = true; // the duel's mark (killEnemy's duel-end read)
e.respawn = false; // a duel fighter is spent — no reform clock
if (this.duel) this.duel.active = true;
this._setActive();
return e;
}
/**
* The FACTION PACK (the picker's 'the whole pack' row): every fighter
* of the faction (combat.factions[id].types) on screen at once — the
* full-strength version of the duel, one roster in (a single
* _clearDuelShips, so the pack doesn't cull itself mid-spawn).
* @param {string} factionId a combat.factions id
* @returns {EnemyShip[]} the pack
*/
spawnDuelPack(factionId) {
this._clearDuelShips();
const out = [];
for (const id of factionTypeIds(factionId)) out.push(this._spawnDuelOne(id));
if (this.duel) this.duel.active = true;
this._setActive();
return out;
}
/** One duel fighter (no roster clear — spawnDuelPack's loop seam). */
_spawnDuelOne(typeId) {
const px = this.ship ? this.ship.x : 0;
const py = this.ship ? this.ship.y : 0;
const a = Math.random() * TAU;
const r = Math.min(this._offscreenRadius(), 520) * (0.7 + Math.random() * 0.2);
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.duel = true;
e.respawn = false;
return e;
}
/** Pull the live duel ships out of the world (a fresh duel replaces them). */
_clearDuelShips() {
for (const e of this.enemies) {
if (e.duel && !e.destroyed) {
this.fx.releaseCraft(e); // out in normal colors, never mid-flash
e.destroy();
}
}
this.enemies = this.enemies.filter((e) => !e.duel);
// their phantoms go with them (a phantom of a craft that is gone
// stands for no one)
for (const il of this.illusions) {
if (il.sprite && !il.sprite.destroyed) il.sprite.destroy();
}
this.illusions = [];
this.tkwaves = [];
if (this.duel) this.duel.active = false;
}
/** The reform clock ran out — the craft is back on a ring edge. */
reviveEnemy(e, time) {
e._killFx = false;
e._respawnPending = false;
e.respawnAt = 0;
const a = Math.random() * TAU;
const r = Math.max(120, e.orbitRadius * (0.8 + Math.random() * 0.5));
const px = this.ship ? this.ship.x : e.x;
const py = this.ship ? this.ship.y : e.y;
e.x = px + Math.cos(a) * r;
e.y = py + Math.sin(a) * r;
e.orbitAngle = a;
e.revive();
e.nextFireAt = time + 900;
e.setVisible(true);
// A new life: the comm rolls re-arm (the pirate comes back with its
// voice — the on-screen roll's edge resets with it).
e._commedAttack = false;
e._wasOnScreen = false;
e.stunUntil = 0; // a new life stands unstrung (the snare's clock resets with it)
this._stunRelease(e, false); // ...and the strung read goes with the old life
this._setActive();
}
// ------------------------------------------------------ the comm rolls
//
// The comming type's VOICE (the comm's CONTENT — the clip, the random
// speech, the line — is data/comms.json's, keyed by the roll's trigger
// name: 'pirateAttack' / 'pirateDefeat', the senders 'pirate' /
// 'pirate_defeat'). The chances are the type's (data/combat.json →
// enemyTypes.<type>.comms); this system only rolls the dice and hands
// the trigger to o.onComm (the scene arms its CommsHub's trigger —
// js/comms/CommsHub.js; a context without one simply doesn't pass the
// seam, and the rolls stay no-ops). The marks ride the ship
// (e._commedAttack: an attack line already fired, so the other attack
// roll doesn't stack; e._wasOnScreen: the on-screen roll's edge), so a
// pirate comms at most once per life — the arrival (20%) OR the
// on-screen (50%) roll, never both.
/**
* A NEUTRAL PILOT's comm (the scan / scan-complete beats): the same seam
* as the pirate rolls — hand the trigger to o.onComm (the scene arms its
* CommsHub's trigger; a context without one stays silent). No chance
* roll: the beats are guaranteed once per encounter (the marks above).
* @param {string} trigger 'neutralScan' / 'neutralScanComplete'
*/
_fireNeutralComm(trigger) {
if (!this.onComm || !trigger) return;
try {
this.onComm(trigger);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
}
}
/**
* The ATTACK roll (`chance` in [0, 1]): a hit arms the type's trigger.
* @param {object} e the hostile (its comms block + the roll's marks)
* @param {number} chance comms.attack.onScreenChance / .spawnChance
* @returns {boolean} true when the comm was armed
*/
_commAttackRoll(e, chance) {
const atk = e?.comms?.attack;
if (!atk || e._commedAttack || !this.onComm) return false;
const trigger = String(atk.trigger ?? '').trim();
const p = Math.min(1, Math.max(0, Number(chance) || 0));
if (p === 0 || trigger === '' || Math.random() >= p) return false;
e._commedAttack = true; // committed — the other attack roll is spent
try {
this.onComm(trigger);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
}
return true;
}
/**
* The DEFEAT roll (comms.defeat — once per death: killEnemy is
* idempotent via _killFx, so a downed pirate gets at most one line).
* @param {object} e the downed hostile
* @returns {boolean} true when the comm was armed
*/
_commDefeatRoll(e) {
const def = e?.comms?.defeat;
if (!def || !this.onComm) return false;
const trigger = String(def.trigger ?? '').trim();
const p = Math.min(1, Math.max(0, Number(def.chance) || 0));
if (p === 0 || trigger === '' || Math.random() >= p) return false;
try {
this.onComm(trigger);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
}
return true;
}
// ------------------------------------------------------------- the bar
/**
* THE HULL PASSIVE (data/skills.json → an effect of kind 'hullPool'): the
* ratio every RANKED hull passive of the run adds together (+5% per point of
* Reinforced Plating → 0.25 at 5/5). Read from the ledger, so it is the same
* number the console shows and it needs no code knowing any skill's id.
*/
get hullBonus() {
const n = Number(this.skillState?.effectTotal?.('hullPool', 'hullBonusPerRank'));
return Number.isFinite(n) && n > 0 ? n : 0;
}
/**
* Re-roll the armor after a rank lands (GameScene calls this when a point is
* spent in the Skill Tree console). THE MULTIPLIER IS RE-ROLLED FROM BASE —
* the same path _applyArmorToLevel() uses, so spending twice, spending before
* a level-up, or spending then loading can only ever produce 1 + 0.05n.
* Growing the pool hands the new hull to the ship WHOLE (the promise of the
* point: +25 hull at full armor is +25 CURRENT hull too — it is not a free
* heal, so a badly damaged ship does not get patched by paperwork); shrinking
* (a future respec) clamps.
* @returns {{maxHull: number, hull: number, bonus: number}}
*/
refreshSkillPassives() {
const beforeMax = Number.isFinite(this.maxHull) ? this.maxHull : 0;
const beforeHull = Number.isFinite(this.hull) ? this.hull : 0;
const level = this.xp ? this.xp.level : 0;
this.baseMaxHull = Math.max(1, this.baseHull + level * this.hullPerLevel);
this.maxHull = Math.max(1, Math.round(this.baseMaxHull * (1 + this.hullBonus)));
const grew = this.maxHull - beforeMax;
this.hull = grew > 0 ? Math.min(this.maxHull, beforeHull + grew) : Math.min(beforeHull, this.maxHull);
return { maxHull: this.maxHull, hull: this.hull, bonus: this.hullBonus };
}
/**
* A skill seat press (keys 1-8 / the bar's middle seats): resolve the seat to
* the skill bound there, gate it, spend its energy, start ITS recharge and
* run its effect (data/skills.json → skills[].effect.kind → SkillEffects).
*
* Every refusal is a REASON, never silence — the bar toasts it (a key press on
* an empty seat, an unranked skill, a thin orb or a skill still recharging
* each say something different), which is why this returns a record instead of
* a boolean.
* @param {number} index 0-7 (seat 1-8's index)
* @returns {{ok: boolean, reason: string|null, seat: number, id: string|null,
* label: string|null, rank: number, energy: number, cost: number,
* cooldownLeft: number}}
*/
cast(index) {
const seat = Math.floor(Number(index));
const no = (reason, extra = {}) => ({
ok: false,
reason,
seat: Number.isInteger(seat) ? seat + 1 : 0,
id: null,
label: null,
rank: 0,
energy: Math.floor(this.energy),
cost: 0,
cooldownLeft: 0,
...extra,
});
if (!skillsEnabled()) return no('disabled');
if (!this.skillState) return no('noLedger');
if (this.playerDown) return no('down');
const row = this.skillState.seatRow?.()[seat];
if (!row || !row.def) return no('empty');
const { def } = row;
if (def.passive) return no('passive', { id: def.id, label: def.label });
// One charge at a time — but the seat is resolved FIRST, so pressing the
// skill that is mid-charge hears about ITS recharge (the honest wall), not a
// generic "busy".
if (this.dash) {
return no(this.dash.id === def.id ? 'cooldown' : 'dashing', {
id: def.id,
label: def.label,
rank: this.skillState.rank(def.id),
cost: def.energy,
cooldownLeft: Math.max(0, this.skillCooldowns.get(def.id) ?? 0),
});
}
const rank = this.skillState.rank(def.id);
const held = Math.max(0, this.skillCooldowns.get(def.id) ?? 0);
if (rank < 1) return no('inert', { id: def.id, label: def.label, cost: def.energy });
if (held > 0) return no('cooldown', { id: def.id, label: def.label, rank, cost: def.energy, cooldownLeft: held });
if (this.energy < def.energy) {
return no('energy', { id: def.id, label: def.label, rank, cost: def.energy, cooldownLeft: held });
}
const handler = SKILL_EFFECTS[def.effect?.kind];
if (typeof handler !== 'function') {
return no('unhandled', { id: def.id, label: def.label, rank, cost: def.energy });
}
// PAY FIRST, then fire: the effect can spend energy of its own (a future
// skill may) and an unaffordable cast can never leak into the world.
this.energy = Math.max(0, this.energy - def.energy);
if (def.cooldown > 0) this.skillCooldowns.set(def.id, def.cooldown);
const out = handler(this, def, rank) ?? {};
return {
ok: true,
reason: null,
seat: seat + 1,
id: def.id,
label: def.label,
rank,
energy: Math.floor(this.energy),
cost: def.energy,
cooldownLeft: this.skillCooldowns.get(def.id) ?? 0,
...out,
};
}
/**
* THE BAR'S SKILL ROW (js/ui/CombatBar.js draws it): one entry per seat —
* what is bound there, its rank, whether it could be cast RIGHT NOW, and the
* recharge fraction for the sweep. Purely derived from the ledger + this
* system's own clocks, so a load with no ledger hands back empty seats.
*/
skillSeats() {
const sheet = iconSheet();
const rows = this.skillState?.seatRow?.() ?? [];
// The seat COUNT is the bar's (data/combat.json → bar.slot.count), so a
// fight with no ledger still hands the bar its eight empty seats.
const count = Math.max(rows.length, this.skillState?.seatCount ?? 0, seatCount());
return Array.from({ length: count }, (_, i) => {
const row = rows[i] ?? {};
const def = row.def ?? null;
const rank = def ? this.skillState.rank(def.id) : 0;
const left = def ? Math.max(0, this.skillCooldowns.get(def.id) ?? 0) : 0;
return {
index: i,
number: i + 1,
id: def?.id ?? null,
label: def?.label ?? null,
frame: def?.frame ?? null,
icon: sheet.texture,
iconFrameWidth: sheet.frameWidth,
typeLabel: def?.type?.label ?? null,
accentCss: def?.type?.accentCss ?? null,
rank,
maxPoints: def?.maxPoints ?? 0,
energy: def?.energy ?? 0,
cooldown: def?.cooldown ?? 0,
cooldownLeft: left,
// The sweep's 0→1 (1 = the recharge is COMPLETE, so nothing is drawn).
cooldownFrac: def && def.cooldown > 0 ? Math.max(0, Math.min(1, 1 - left / def.cooldown)) : 1,
affordable: def ? this.energy >= def.energy : false,
ready: !!def && rank >= 1 && left <= 0 && this.energy >= def.energy && !this.dash && !this.playerDown,
inert: !!def && rank < 1,
};
});
}
/**
* Start a charge (the 'dashBlast' handler's seam — call cast(), not this).
* The aim point is the nearest hostile AT CAST plus the data's overshoot, so
* the ship ends up THROUGH the enemy; the speed is the ship's own max times
* the data's multiple, held for the data's duration (whichever ends first —
* arrival or the clock — lands the blast).
* @returns {{id: string, rank: number, toX: number, toY: number, speed: number,
* distance: number, durationMs: number, blastRadiusPx: number}|null}
*/
beginDash(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'dashBlast') return null;
const eff = def.effect;
const target = this.nearestEnemy(ship.x, ship.y);
const aim = dashPoint(ship.x, ship.y, target, eff.overshootPx);
// The ship's OWN top speed is the base (the hull being charged is what gets
// faster — a heavier ship charges slower); data/combat.json → ship.maxSpeed
// is the fallback for a craft that never read it.
const base = Number(ship.maxSpeed) || Number(config.get('ship.maxSpeed', 0)) || 0;
const speed = base * (Number(eff.speedMul) || 1);
this.dash = {
id: def.id,
rank,
fx: eff.fx ?? {},
toX: aim.x,
toY: aim.y,
speed,
blastRadiusPx: Number(eff.blastRadiusPx) || 0,
durationMs: Math.max(1, Number(eff.durationMs) || 1),
elapsedMs: 0,
fromX: ship.x,
fromY: ship.y,
};
// THE COMET HEAD (js/combat/Fx.js → cometStart) — the fireball that
// rides over the hull for the whole charge (flicker + follow are Fx's
// per-frame business); _endDash releases it the instant the ram lands.
this._dashComet = this.fx.cometStart(ship, aim.angle, Math.max(300, this.dash.durationMs));
// THE CONTROLS ARE LOCKED for the charge: whatever the player was flying at
// (the click-to-fly target / the throttle lock) is dropped, every frame of
// the dash (a click made MID-charge is not queued up to yank the ship later).
this._releaseSteering();
return { ...this.dash, distance: aim.distance };
}
/** Drop the player's steering (the dash owns the hull; see beginDash). */
_releaseSteering() {
const ship = this.ship;
if (!ship) return;
ship.target = null;
ship.thrustDir = null;
if (ship.body) {
// The charge sets its own velocity — the flight model's acceleration and
// drag would otherwise be fighting it every frame.
ship.body.acceleration?.set?.(0, 0);
}
}
/**
* The charge's frame step (update()'s step 0): drive the hull toward the aim
* point at the fixed speed, hold the steering off, and end the dash when the
* point is reached or the data's clock runs out — either way the blast fires
* where the ship stops.
*/
_stepDash(dt) {
const d = this.dash;
if (!d) return null;
const ship = this.ship;
if (!ship || this.playerDown) return this._endDash(); // a dead/lost hull ends it (the blast lands where it stopped)
this._releaseSteering();
const dx = d.toX - ship.x;
const dy = d.toY - ship.y;
const dist = Math.hypot(dx, dy);
// The frame's move is clamped to the clock that is LEFT: a charge covers
// exactly speed × duration and no more, however the frames happen to fall.
const step = d.speed * Math.min(dt, Math.max(0, d.durationMs - d.elapsedMs) / 1000);
let arrived = false;
if (dist <= 0.001) {
arrived = true;
} else if (step >= dist) {
ship.x = d.toX;
ship.y = d.toY;
arrived = true;
} else {
ship.x += (dx / dist) * step;
ship.y += (dy / dist) * step;
}
if (ship.body) ship.body.velocity.set((dx / (dist || 1)) * d.speed, (dy / (dist || 1)) * d.speed);
// The WAKE (js/combat/Fx.js → trailEmbers): a white-hot lead, a hot
// core and shed strays that linger ~1 s behind the hull and cool —
// the comet's trail (the data's trailColor, with fallbacks in Fx).
this.fx.trailEmbers(ship.x, ship.y, Math.atan2(dy, dx), hexToInt(d.fx.trailColor, 0xff3a4d));
d.elapsedMs += dt * 1000;
if (arrived || d.elapsedMs >= d.durationMs) return this._endDash();
return null;
}
/** Land the charge: stop dead (the ram is over), release the fireball, set the world off. */
_endDash() {
const d = this.dash;
this.dash = null;
if (!d) return null;
const ship = this.ship;
if (this._dashComet) { this.fx.cometStop(this._dashComet); this._dashComet = null; } // the impact supersedes the head
if (ship?.body) {
ship.body.velocity.set(0, 0);
ship.body.acceleration?.set?.(0, 0);
}
const def = this.skillState?.defOf?.(d.id) ?? null;
const damage = blastDamage(def?.effect ?? {}, d.rank, baseWeaponDamage());
return { ...this.skillBlast(ship ? ship.x : d.toX, ship ? ship.y : d.toY, d.blastRadiusPx, damage, d.fx), id: d.id, rank: d.rank };
}
/**
* A radial hit (the arrival blast, and any future AoE): everything of the
* pack whose hull touches inside `radius` takes the SAME hit once, the shock
* ring draws at the data's size, and the voice is the data's sfxEvent.
* @returns {{hits: number, kills: number, damage: number}}
*/
skillBlast(x, y, radius, damage, fx = {}) {
const hit = radiusHits(this._playerPool(), x, y, radius);
let kills = 0;
for (const e of hit) {
const preShields = e.shields;
const killed = e.damage(damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, x, y, Math.atan2(e.y - y, e.x - x), kind, damage);
this.fx.damage(e.x, e.y, damage, kind);
if (killed) {
kills += 1;
this.killEnemy(e, this._now());
}
}
// THE ARRIVAL (js/combat/Fx.js → ramBlast): the white-hot ignition,
// the colored fireball, the ring that settles EXACTLY on the damage
// radius, the shock ripples and the debris — every number from the
// data's fx block (with fallbacks).
this.fx.ramBlast(x, y, radius, fx);
// The voice: the data's sfxEvent (the heavy armor-crack) + the thunder
// boom underneath (the COMBAT_SFX 'kill' voice — the big impact).
combatSfx(this.scene, String(fx.sfxEvent || 'hull'));
combatSfx(this.scene, 'kill', { vol: 0.8 });
// The world kick — the RAM's beat (stronger than a kill, js/combat/CamShake.js).
combatShake(this.scene, 'ram');
return { hits: hit.length, kills, damage };
}
/**
* THE WRAITH'S BOLAAS (data/skills.json → effect.kind 'boleadoras'):
* one set per NEAREST hostile (effect.targetCount of them), each one
* hunting its assigned craft on the advanced targeting's solve. The
* handler's seam — call cast(), not this.
* @returns {{count: number, stunMs: number, speed: number}|null}
*/
launchBoleadoras(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'boleadoras') return null;
const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0);
const count = Math.max(0, Math.floor(Number(eff.targetCount) || 0));
// The N NEAREST live, UN-CLOAKED hostiles the player may engage (a
// phased-out craft can't be targeted — and has no on-screen filter —
// the throw is cast from the hull, the way the dash aims: at
// whatever's nearest in view; a neutral-to-the-player faction stands
// — the war-zone's pool rule).
const nearest = this._playerPool()
.filter((e) => !e.dead && !e._removed && !e.cloaked)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count);
const stun = stunMs(eff, rank);
for (const t of nearest) {
const b = {
x: ship.x,
y: ship.y,
target: t,
speed,
stun,
capture: Math.max(0, Number(eff.captureRadiusPx) || 0),
fx: eff.fx ?? {},
toX: t.x,
toY: t.y,
};
// THE SET (js/combat/Fx.js → bolaStart): the two orbs on their string.
b.handle = this.fx.bolaStart(b.x, b.y, b.fx);
this.bolas.push(b);
}
if (nearest.length > 0) this.fx.flash(ship.x, ship.y, hexToInt(eff.fx?.ballColor, 0xb489ff), 34, 160);
return { count: nearest.length, stunMs: stun, speed };
}
/**
* The bola sets' frame step (update()'s step 0b). Each set re-solves its
* craft's intercept EVERY frame (the Wraith's advanced targeting —
* js/combat/Aiming.js's intercept, capped by the ledger's
* perks.targetingComputer.maxLead when it carries the computer) and flies
* at effect.speed toward the solve. The set ALWAYS lands — the solve
* converges (the sets fly faster than the pack) — and on landing the snag
* drops the stun on whatever is caught inside effect.captureRadiusPx. A
* craft that dies mid-flight leaves its set on its last sight (a fixed
* point the set still reaches and snags at).
*/
_stepBoleadoras(dt, time) {
if (this.bolas.length === 0) return;
const keep = [];
const lead = this.classState?.hasPerk?.('targetingComputer')
? Number(perkDef('targetingComputer')?.maxLead) || 0
: 0;
for (const b of this.bolas) {
// THE WRAITH'S SOLVE (every frame, js/combat/Aiming.js's intercept):
// the set flies where the craft WILL be, not where it is.
const t = b.target;
if (t && !t.dead && !t._removed) {
const hit = intercept(b.x, b.y, t.x, t.y, t.body?.velocity?.x ?? 0, t.body?.velocity?.y ?? 0, b.speed, lead);
b.toX = hit.x;
b.toY = hit.y;
}
const dx = b.toX - b.x;
const dy = b.toY - b.y;
const dist = Math.hypot(dx, dy);
const step = b.speed * dt;
if (dist > 0.001 && step < dist) {
b.x += (dx / dist) * step;
b.y += (dy / dist) * step;
this.fx.bolaStep(b.handle, dt, b.x, b.y, Math.atan2(dy, dx));
keep.push(b);
} else {
// THE SET LANDS — whatever is caught inside the capture radius is caught.
this._snagBola(b, time);
this.fx.bolaStop(b.handle);
}
}
this.bolas = keep;
}
/**
* THE SNAG (a bola set lands): every live hostile whose hull touches the
* capture radius is strung — flight and fire held until `stunUntil` (the
* data's base + per-rank ms on the combat clock) — and the read is the
* data's (Fx.snag's ring + sparks + flash, the data's sfxEvent voice).
*/
_snagBola(b, time) {
const caught = radiusHits(this._playerPool(), b.x, b.y, b.capture);
for (const e of caught) {
e.stunUntil = Math.max(e.stunUntil || 0, time + b.stun);
// THE STRUNG READ's data (the hull's dim + the discharge's color) —
// the read itself lives in _stunHoldFrame/_stunRelease + Fx.stunArms.
e._stunColor = hexToInt(b.fx.ballColor, 0xb489ff);
e._stunAlpha = Math.max(0.1, Math.min(1, Number(b.fx?.stunAlpha) || 0.42));
this.fx.snag(e, e._stunColor);
}
if (caught.length > 0) {
combatSfx(this.scene, String(b.fx.sfxEvent || 'bola_snag'));
} else {
this.fx.flash(b.x, b.y, hexToInt(b.fx.ballColor, 0xb489ff), 26, 160); // an empty catch still pops
}
}
/**
* THE STRUNG READ's per-frame keep (the snare's gate calls it while the
* stun holds): the hull DIMS to the data's fx.stunAlpha (the disabled
* read) and the discharge (Fx.stunArms) is live — both driven by the
* same `stunUntil` the gate reads. Idempotent: one dim, one discharge;
* a re-snag extends the discharge's life with the snare's clock.
*/
_stunHoldFrame(e, time) {
if (!e._stunDimmed) {
e._stunBaseAlpha = Number.isFinite(e.alpha) ? e.alpha : 1;
const dim = e._stunAlpha ?? 0.42;
if (typeof e.setAlpha === 'function') {
try { e.setAlpha((e._stunBaseAlpha || 1) * dim); } catch { /* a stub without alpha */ }
}
e._stunDimmed = true;
}
if (e._stunArms && !e._stunArms.killed) {
// a RE-SNAG (overlapping sets) extends the discharge with the snare
e._stunArms.life = Math.max(e._stunArms.life, e._stunArms.t0 + Math.max(0, (e.stunUntil || 0) - time));
} else {
e._stunArms = this.fx.stunArms(e, e._stunColor ?? 0xb489ff, Math.max(150, (e.stunUntil || time) - time));
}
}
/**
* THE RELEASE (the snare's clock ran out — or the kill/revive seams):
* the discharge settles (Fx.killStunArms), the hull's alpha comes
* back, and — only when the craft is alive — a small beat marks the
* moment it snaps back to its own power.
* @param {object} e the strung hostile
* @param {boolean} cue play the release beat (the stun expiry's edge)
*/
_stunRelease(e, cue = true) {
const color = e._stunColor ?? 0xb489ff;
if (e._stunArms) {
this.fx.killStunArms(e);
e._stunArms = null;
}
if (e._stunDimmed) {
e._stunDimmed = false;
if (typeof e.setAlpha === 'function') {
try { e.setAlpha(e._stunBaseAlpha ?? 1); } catch { /* the craft may be gone */ }
}
}
e._stunColor = null;
e._stunAlpha = null;
if (cue && !e.dead && !e.destroyed) {
this.fx.flash(e.x, e.y, color, 44, 200);
this.fx.sparks(e.x, e.y, 0xffffff, 8, 170, 320);
}
}
/**
* THE WRAITH'S BLINK (data/skills.json → effect.kind 'blink'): the hull
* is swapped for its DECOY — a copy of the player's own ship (effect.
* decoyFrame of the player's own sheet) left at the old position — and
* the Wraith stands at a random point of the visible screen. While the
* decoy stands (effect.decoyBaseMs + decoyPerRankMs × rank) it carries
* every pirate's aggro (pickEnemyTarget's first rule) and their bolts
* that reach it fizzle (collide's decoy pass).
* @returns {{fromX: number, fromY: number, toX: number, toY: number, decoyMs: number}|null}
*/
blink(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'blink') return null;
const eff = def.effect;
const dur = Math.max(1, decoyMs(eff, rank));
const fromX = ship.x;
const fromY = ship.y;
const to = this._blinkDestination(eff);
// THE DECOY — the player's OWN hull copied (its texture, the data's
// decoy frame, its scale) in the data's hologram tint.
const key = ship.texture?.key ?? '__ship';
const frameTotal = Math.max(1, Math.floor(Number(ship.texture?.frameTotal) || 1));
const frame = Math.min(Math.max(0, Math.floor(Number(eff.decoyFrame) || 0)), frameTotal - 1);
const sprite = new Phaser.GameObjects.Sprite(this.scene, fromX, fromY, key, frame);
sprite.setDepth(10);
sprite.setScale(ship.scale ?? 1);
try { sprite.setTint(hexToInt(eff.fx?.decoyColor, 0xb489ff)); } catch { /* the stub scene has no tint — the copy stands plain */ }
if (typeof this.scene?.add?.existing === 'function') this.scene.add.existing(sprite);
assignWorld(this.scene, sprite);
const decoy = {
sprite,
x: fromX,
y: fromY,
hitRadius: Math.max(12, Number(ship.radius) || 14),
until: this._now() + dur,
fx: eff.fx ?? {},
dead: false,
};
this.decoys.push(decoy);
// THE HOP — the hull stands somewhere else on the visible screen, and
// the steering is released (a teleport, not a dash: the click-to-fly
// target the player had set is not queued up to drag the ship back).
ship.x = to.x;
ship.y = to.y;
if (ship.body) {
ship.body.velocity.set(0, 0);
ship.body.acceleration?.set?.(0, 0);
}
this._releaseSteering();
// THE READ — the old position's dissolve (the decoy inherits it), the
// new position's arrival, and the voice.
const col = hexToInt(eff.fx?.color, 0xb489ff);
this.fx.ring(fromX, fromY, col, 130, 380);
this.fx.flash(fromX, fromY, 0xffffff, 36, 200);
this.fx.ring(to.x, to.y, col, 130, 380);
this.fx.sparks(to.x, to.y, col, 12, 260, 520);
combatSfx(this.scene, String(eff.fx?.sfxEvent || 'blink'));
return { fromX, fromY, toX: to.x, toY: to.y, decoyMs: dur };
}
/**
* The blink's destination: a random point of the visible screen inset by
* effect.insetFraction of the shorter side (the destination is ON the
* screen — the data's rule), at least effect.minDistancePx from the old
* position (a blink that lands in place is not a blink). No camera (the
* headless range) → a random bearing at the data's fallback radius.
*/
_blinkDestination(eff = {}) {
const ship = this.ship;
const minGap = Math.max(0, Number(eff.minDistancePx) || 0);
const v = this.visibleView();
if (v) {
const inset = Math.min(0.5, Math.max(0, Number(eff.insetFraction) || 0)) * Math.min(v.width, v.height);
const w = Math.max(1, v.width - 2 * inset);
const h = Math.max(1, v.height - 2 * inset);
for (let i = 0; i < 8; i++) {
const x = v.x + inset + Math.random() * w;
const y = v.y + inset + Math.random() * h;
if (!ship || Math.hypot(x - ship.x, y - ship.y) >= minGap) return { x, y };
}
return { x: v.x + v.width / 2, y: v.y + v.height / 2 }; // the screen is too small for the gap — the centre stands
}
const a = Math.random() * TAU;
const r = Math.max(minGap, Number(eff.fallbackRadiusPx) || 0);
return { x: (ship?.x ?? 0) + Math.cos(a) * r, y: (ship?.y ?? 0) + Math.sin(a) * r };
}
/**
* The decoys' frame step (update()'s step 0b): while its clock holds the
* shell shimmers (a hologram is not a hull — it flickers), and in its
* last 250 ms it fades out; at expiry it is collected with a small pop.
*/
_stepDecoys(time) {
if (this.decoys.length === 0) return;
const keep = [];
for (const d of this.decoys) {
if (time >= d.until) {
d.dead = true;
const col = hexToInt(d.fx.decoyColor, 0xb489ff);
this.fx.flash(d.sprite.x, d.sprite.y, col, 44, 180);
this.fx.sparks(d.sprite.x, d.sprite.y, col, 8, 200, 420);
if (d.sprite && !d.sprite.destroyed) d.sprite.destroy();
continue;
}
const left = d.until - time;
const spr = d.sprite;
if (spr && typeof spr.setAlpha === 'function') {
const fade = Math.min(1, left / 250);
spr.setAlpha(fade * (0.85 + 0.15 * Math.sin(time * 0.02)));
}
keep.push(d);
}
this.decoys = keep;
}
/**
* THE NANOBOTS (data/skills.json → an effect of kind 'hullRegen'): the
* mending a ranked Nanobots ledger grants — the fraction of MAX hull each
* tick regrows (0.01 per point, summed by the ledger — 0.05 at 5/5), the
* tick's clock in MS (effect.intervalMs, the data's own number off the
* ranked skill), and the color each mend's beat wears (fx.mendColor).
* Inert (all zero) when nothing is ranked — the combat loop then mends
* nothing.
* @returns {{fraction: number, intervalMs: number, color: (string|null)}}
*/
hullRegen() {
const st = this.skillState;
if (!st) return { fraction: 0, intervalMs: 0, color: null };
const fraction = Number(st.effectTotal('hullRegen', 'regenPercentPerRank'));
if (!Number.isFinite(fraction) || fraction <= 0) return { fraction: 0, intervalMs: 0, color: null };
const def = st.firstPassiveOf('hullRegen');
const interval = Number(def?.effect?.intervalMs);
return {
fraction,
intervalMs: Number.isFinite(interval) && interval > 0 ? interval : 1000,
color: def?.effect?.fx?.mendColor ?? null,
};
}
/**
* THE VANGUARD'S INFERNO (data/skills.json → effect.kind 'flameArc'):
* one wide arc of flame per nearest hostile (effect.targetBase of them —
* effect.extraTargets more from effect.extraTargetAtRank on), each a fast
* wave that homes on its assigned craft and, on arrival, ignites every
* live hostile its splash (effect.splashRadiusPx) touches. The burn each
* caught craft wears (this cast's data numbers, stamped on the craft) is
* stepped by the combat loop — the damage outlives the arc.
* The handler's seam — call cast(), not this.
* @param {object} def the model's def (classId filled)
* @param {number} rank points spent
* @returns {{count: number, speed: number, burnMs: number, tickMs: number, damage: number}|null} the cast's plan (null when there is no ship)
*/
launchFlames(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'flameArc') return null;
const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0);
const count = flameTargetCount(eff, rank);
// Cloaked hostiles can't be targeted — the wave passes them by (and a
// neutral-to-the-player faction stands — the war-zone's pool rule).
const nearest = this._playerPool()
.filter((e) => !e.dead && !e._removed && !e.cloaked)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count);
const burn = burnDurationMs(eff, rank);
const tick = Math.max(1, Number(eff.burnTickMs) || 0);
const damage = burnTickDamage(eff, rank);
for (const t of nearest) {
this.flames.push({
x: ship.x,
y: ship.y,
target: t,
toX: t.x,
toY: t.y,
speed,
splash: Math.max(0, Number(eff.splashRadiusPx) || 0),
burn,
tick,
damage,
fx: eff.fx ?? {},
});
}
if (nearest.length > 0) {
const col = hexToInt(eff.fx?.flameColor, 0xff9d3a);
this.fx.flash(ship.x, ship.y, col, 34, 160);
this.fx.sparks(ship.x, ship.y, col, 8, 200, 380);
}
return { count: nearest.length, speed, burnMs: burn, tickMs: tick, damage };
}
/**
* The flame arcs' frame step (update()'s step 0b): each arc chases its
* craft (re-solving the position every frame — the craft may have flown)
* and flies at the data's speed; a craft that dies mid-flight leaves the
* arc on its last sight, which the arc still reaches and ignites at.
* On landing the splash ignites (and its read plays).
*/
_stepFlames(dt, time) {
if (this.flames.length === 0) return;
const keep = [];
for (const f of this.flames) {
const t = f.target;
if (t && !t.dead && !t._removed) {
f.toX = t.x;
f.toY = t.y;
}
const dx = f.toX - f.x;
const dy = f.toY - f.y;
const dist = Math.hypot(dx, dy);
const step = f.speed * dt;
if (dist > 0.001 && step < dist) {
f.x += (dx / dist) * step;
f.y += (dy / dist) * step;
// THE WAVE's read: a hot head shedding embers behind it (the data's
// colors, with fallbacks) — Fx's ember is the fixed craft.
this.fx.ember(f.x, f.y, hexToInt(f.fx.flameColor, 0xff9d3a), 16, 260, 30, 0.7);
keep.push(f);
} else {
this._igniteFlames(f, time);
}
}
this.flames = keep;
}
/**
* THE IGNITION (a flame arc lands, at the combat clock's `time`): every
* live hostile whose hull touches the splash radius is set alight — its
* burn clock starts/extends to this cast's burn for the rank from now,
* the first tick fires at once (the t=0 rule), and the damage and tick it
* wears are THIS cast's data numbers whenever they are the stronger wear
* (a rank 5 burn overwrites a rank 1's, never the other way round).
*/
_igniteFlames(f, time) {
const col = hexToInt(f.fx.flameColor, 0xff9d3a);
const ember = hexToInt(f.fx.emberColor, 0xffd84d);
const caught = radiusHits(this._playerPool(), f.x, f.y, f.splash);
for (const e of caught) {
if (e.dead || e._removed) continue;
e.burnUntil = Math.max(e.burnUntil || 0, time + f.burn);
e.burnNextAt = Math.min(e.burnNextAt ?? time, time); // a fresh impact ticks at once
if (f.damage >= (e.burnDamage || 0)) {
e.burnDamage = f.damage; // the stronger burn wears
e.burnTickMs = f.tick;
}
e.burnColor = col;
e._burnFxAt = 0; // the embers restart on a fresh impact
}
if (caught.length > 0) {
this.fx.flash(f.x, f.y, col, 44, 200);
this.fx.sparks(f.x, f.y, ember, 10, 220, 420);
this.fx.ring(f.x, f.y, col, 90, 380);
combatSfx(this.scene, String(f.fx.sfxEvent || 'hull'));
} else {
this.fx.flash(f.x, f.y, col, 26, 140); // a whiffed arc still pops where it landed
this.fx.ember(f.x, f.y, ember, 14, 300, 40, 0.8);
}
}
/**
* THE BURNS' frame step (update()'s step 0b): every alight craft ticks
* its burnDamage every burnTickMs while its burn clock holds — the t=0
* tick already fired on ignition — shedding embers off its hull each
* beat, and the expiry's edge settles with a small beat (the fire goes
* out). A burn that kills its craft hands the kill to the usual seam.
* (The tick loop catches up any intervals the clock overshot in one frame,
* capped — a dropped frame never loses a burn's damage.)
*/
_stepBurns(time) {
for (const e of this.enemies) {
if (!e.burnUntil) continue;
if (e.dead || e._removed) {
e.burnUntil = 0; // the craft is gone — its burn goes with it
continue;
}
const col = e.burnColor ?? 0xff9d3a;
// THE BURN'S OWN READ — embers shed off the hull while it holds.
if (time >= (e._burnFxAt ?? 0)) {
e._burnFxAt = time + 140;
this.fx.ember(e.x, e.y, col, 14, 420, 34, 0.7);
}
if (e.burnDamage > 0 && time >= (e.burnNextAt ?? 0)) {
let ticks = 0;
while (e.burnNextAt <= time && ticks < 8) {
e.burnNextAt += e.burnTickMs || 500;
const killed = e.damage(e.burnDamage);
this.fx.sparks(e.x, e.y, col, 5, 180, 360);
this.fx.damage(e.x, e.y, e.burnDamage, 'hull');
if (killed) {
this.killEnemy(e, this._now());
break;
}
ticks += 1;
}
}
if (time >= e.burnUntil) {
// THE FIRE GOES OUT — the hull clears of embers with the beat.
this.fx.flash(e.x, e.y, col, 30, 160);
this.fx.sparks(e.x, e.y, col, 6, 160, 320);
e.burnUntil = 0;
e.burnNextAt = null;
e.burnDamage = 0;
e.burnTickMs = 0;
}
}
}
/** The per-skill recharges (update()'s step 5b). */
_tickSkillCooldowns(dt) {
if (this.skillCooldowns.size === 0) return;
for (const [id, left] of [...this.skillCooldowns]) {
const next = left - dt;
if (next <= 0) this.skillCooldowns.delete(id);
else this.skillCooldowns.set(id, next);
}
}
/** A snapshot for the combat bar (shields/hull left, energy right) +
* the XP row (the xp bank's state — level, the run toward the next,
* the numbers). */
stats() {
return {
hull: this.hull,
maxHull: this.maxHull,
shields: this.shields,
maxShields: this.maxShields,
energy: this.energy,
maxEnergy: this.maxEnergy,
hullBonus: this.hullBonus, // the passive's share of the pool (the orbs' max)
seats: this.skillSeats(), // the bar's skill row (js/ui/CombatBar.js)
dashing: !!this.dash,
playerDown: this.playerDown,
xp: this.xp ? this.xp.stats() : null,
};
}
/** Tear the combat down (scene destroy). */
destroy() {
this.dash = null; // a charge in flight dies with the fight (no blast)
this.skillCooldowns.clear(); // recharges are per-fight: a new fight starts ready
for (const b of this.bolas) this.fx.bolaStop(b.handle); // the Wraith's sets die with the fight
this.bolas = [];
for (const d of this.decoys) if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); // and its decoys
this.decoys = [];
this.flames = []; // the arcs die with the fight (no ignition in flight)
// The enemy faction's hands die with it: the tides and the phantoms
// (their sprites go too — no shimmer outlives the fight).
for (const il of this.illusions) if (il.sprite && !il.sprite.destroyed) il.sprite.destroy();
this.illusions = [];
this.tkwaves = [];
this.duel = null;
this._nanobotAccumMs = 0;
this.waveRun = null;
if (this._levelUnsub) {
this._levelUnsub();
this._levelUnsub = null;
}
for (const e of this.enemies) {
this.fx.releaseCraft(e); // settle flashes before the craft goes
e.destroy();
}
for (const a of this.allies) {
this.fx.releaseCraft(a);
a.destroy();
}
for (const b of this.bullets) if (!b.dead) b.destroy();
this.enemies = [];
this.allies = [];
this.bullets = [];
this.fx.clear();
this.ship = null;
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
this.onPlayerUp = this.onKill = this.onLevelUp = null;
}
}