orbit/js/combat/CombatSystem.js

1862 lines
79 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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 } 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 { barrels as barrelLine, intercept } from './Aiming.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, dashPoint, hexToInt, radiusHits } 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';
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 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 a
// fresh scene is a fresh ship (full armor, whatever the level).
this._applyArmorToLevel();
this.hull = this.maxHull;
this.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 = this.maxEnergy;
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;
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._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.
* @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base
*/
spawnEnemy(x, y, index, type = null) {
const cfg = enemyTypeConfig(type);
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 = 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);
// 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 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);
const wantsFire = e.update(time, delta, target);
if (wantsFire && target && 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.
for (const a of this.allies) {
if (a.dead) continue;
const r = a.update(time, delta, player, this.enemies);
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();
}
// 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.enemies,
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);
// 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);
}
}
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 hostile to (x, y), or null (the shared pickTarget rule). */
nearestEnemy(x, y) {
return pickTarget(this.enemies, 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.enemies,
this.ship.x,
this.ship.y,
this.autoFire.range,
(e) => this.craftVisible(e),
);
}
/**
* 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).
* @returns {object|null} the craft to orbit + fire at (null = player down)
*/
pickEnemyTarget(e, player) {
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;
}
/**
* 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) {
return firePattern(this, e, player, e.wpn ?? {});
}
/**
* 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.enemies, 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,
});
b.setDepth(20);
this.bullets.push(b);
return b;
}
// ------------------------------------------------------------- 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) {
// Your tracers × hostiles.
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.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).
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.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 × you.
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 (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);
// 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) {
if (!e.dead) 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;
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;
}
}
}
}
/** 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.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 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) {
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);
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 (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');
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).
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);
this._setActive();
}
/** 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;
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 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 streak: a spark every frame along the heading reads as a ram's wake.
this.fx.sparks(ship.x, ship.y, hexToInt(d.fx.trailColor, 0xff3a4d), 2, 60, 220, Math.atan2(dy, dx) + Math.PI, 0.6);
d.elapsedMs += dt * 1000;
if (arrived || d.elapsedMs >= d.durationMs) return this._endDash();
return null;
}
/** Land the charge: stop dead (the ram is over) and set the world off. */
_endDash() {
const d = this.dash;
this.dash = null;
if (!d) return null;
const ship = this.ship;
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.enemies, 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());
}
}
const ring = hexToInt(fx.ringColor, 0xffd84d);
this.fx.ring(x, y, ring, Math.max(24, (Number(radius) || 0) * 2), Number(fx.ringLifeMs) || 320);
this.fx.flash(x, y, ring, Math.max(16, Number(radius) || 0), 180);
combatSfx(this.scene, String(fx.sfxEvent || 'hull'));
return { hits: hit.length, kills, damage };
}
/** 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
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;
}
}