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