orbit/js/combat/CombatSystem.js

1393 lines
57 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 { enemyTypeConfig, neutralTypeConfig } 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 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.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
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).
this.allies.some((a) => !a.dead && !a.gone && !a._removed)
);
}
/** 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 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 escortMs =
Number.isFinite(Number(o.escortMs)) && Number(o.escortMs) > 0
? Number(o.escortMs)
: (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;
}
// ----------------------------------------------------------------- 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;
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;
}
// 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);
}
}
// 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);
// 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) {
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),
);
}
/**
* 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', {});
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 ?? {});
}
/**
* 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).
if (player) {
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.
/**
* 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
/**
* 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 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;
}
}