1393 lines
57 KiB
JavaScript
1393 lines
57 KiB
JavaScript
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;
|
||
}
|
||
}
|