1040 lines
42 KiB
JavaScript
1040 lines
42 KiB
JavaScript
import { config } from '../config/Config.js';
|
||
import { EnemyShip } from './EnemyShip.js';
|
||
import { enemyTypeConfig } 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 { 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 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)
|
||
*/
|
||
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.enemies = [];
|
||
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
|
||
|
||
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._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),
|
||
);
|
||
}
|
||
|
||
/** Fire the state change at the scene (bar swap) when it flips. */
|
||
_setActive() {
|
||
const a = this.active;
|
||
if (a === this._active) return;
|
||
this._active = a;
|
||
if (this.onActiveChange) this.onActiveChange(a);
|
||
}
|
||
|
||
/**
|
||
* 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 e = new EnemyShip(this.scene, x, y, index, enemyTypeConfig(type));
|
||
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;
|
||
}
|
||
|
||
// ----------------------------------------------------------------- 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);
|
||
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;
|
||
this.maxHull = Math.max(1, this.baseHull + level * this.hullPerLevel);
|
||
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;
|
||
|
||
// 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;
|
||
}
|
||
const wantsFire = e.update(time, delta, player);
|
||
if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time);
|
||
}
|
||
|
||
// 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) {
|
||
this.revivePlayer(time);
|
||
}
|
||
|
||
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),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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', {});
|
||
this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
|
||
const a =
|
||
Math.atan2(target.y - player.y, target.x - player.x) +
|
||
(Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
|
||
const nose = (Number(player.radius) || 12) + 8;
|
||
const x = player.x + Math.cos(a) * nose;
|
||
const y = player.y + Math.sin(a) * nose;
|
||
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'); // the mass-cannon voice (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 ?? {});
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
// 2b) Heat-seekers × you (js/combat/Weapons.js): the missile's damage
|
||
// lands from its incoming bearing; the hit blooms + consumes it.
|
||
if (player) missilesHitPlayer(this, player, Number(player.radius) || 12, delta);
|
||
// Rams (you × the pack) — push apart, bounce, damage both (grace-limited).
|
||
if (player) {
|
||
for (const e of this.enemies) {
|
||
if (!e.dead) this.ramPair(player, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------- 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');
|
||
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);
|
||
this._setActive();
|
||
}
|
||
|
||
// ------------------------------------------------------------- the bar
|
||
|
||
/**
|
||
* A skill slot press (keys 1-8 / the bar's middle seats). The skills
|
||
* land later — for now the seats are empty: the bar calls this, and
|
||
* this is where one will spend energy and do the thing.
|
||
* @param {number} index 0-7 (slot 1-8's index)
|
||
*/
|
||
cast(index) {
|
||
// (the skill registry lands with the real combat port)
|
||
void index;
|
||
}
|
||
|
||
/** 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,
|
||
playerDown: this.playerDown,
|
||
xp: this.xp ? this.xp.stats() : null,
|
||
};
|
||
}
|
||
|
||
/** Tear the combat down (scene destroy). */
|
||
destroy() {
|
||
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 b of this.bullets) if (!b.dead) b.destroy();
|
||
this.enemies = [];
|
||
this.bullets = [];
|
||
this.fx.clear();
|
||
this.ship = null;
|
||
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
|
||
this.onPlayerUp = this.onKill = this.onLevelUp = null;
|
||
}
|
||
}
|