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..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 MINDWEAVE'S WILL (data/combat.json → bulletHoming): the homing // stamp for listed factions' bolts (spawnBullet) + their bend (the // stepHomingBullets pass, run in update before the collisions). this.bulletHoming = config.section('combat.bulletHoming', {}); // 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..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. FACTION roster fighters (isFactionType) get their combat * damage/armor at the player's current combat level (the * combat.factionScaling ramp — the 2×/3.5× pirate ratio scales down to * 1.2×/2.0× at level 10; js/combat/EnemyTypes.js → * enemyTypeConfig(type, level)). * @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base */ spawnEnemy(x, y, index, type = null) { const level = this.xp ? this.xp.level : null; const cfg = enemyTypeConfig(type, level); const e = new EnemyShip(this.scene, x, y, index, cfg); // The comm rolls' config (data/combat.json → enemyTypes..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); // 3c) THE MINDWEAVE'S WILL (stepHomingBullets): the listed factions' // bolts bend toward the nearest live hostile before the // collisions read this frame's travel. this.stepHomingBullets(delta); // 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} 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; // THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): a listed // faction's bolt/shell bends toward the nearest live hostile for the // first `durationMs` of flight — the weaver's threads find their mark // without negating a committed dodge (the bend is turn-rate capped). const bh = this.bulletHoming; if (owner === 'enemy' && b.factionId && Array.isArray(bh.factions) && bh.factions.includes(b.factionId)) { b.homing = { turn: Math.max(0, Number(bh.turnRate) || 0), left: Math.max(0, Number(bh.durationMs) || 0), range: Math.max(40, Number(bh.range) || 0), }; } b.setDepth(20); this.bullets.push(b); return b; } /** * THE MINDWEAVE'S WILL (data/combat.json → bulletHoming): the first * `durationMs` of flight, a listed faction's bolts and shells bend * toward the NEAREST LIVE HOSTILE within range — the threads find their * mark, subtle yet effective (a capped turn-rate: a hard dodge still * wins, a straight shot no longer misses). Hostiles = the rival * faction's wing (never the shooter's own — no friendly homing) and, * for bolts hostile to the player, the player itself. War-zone fire * (hostileToPlayer false) never homes on the player — the will only * curves the fire at the rival wing. * @param {number} delta frame delta (ms) */ stepHomingBullets(delta) { const bs = this.bullets; if (!bs || bs.length === 0) return; const dt = Math.min(delta, 64) / 1000; const player = this.playerDown ? null : this.ship; for (let i = bs.length - 1; i >= 0; i--) { const b = bs[i]; const h = b.homing; if (b.dead || !h || h.left <= 0) continue; h.left -= delta; if (h.left <= 0 || h.turn <= 0) continue; let bx = 0, by = 0, bd = Infinity; for (const e of this.enemies) { if (e.dead || e.cloaked) continue; if (!e.factionId || e.factionId === b.factionId) continue; // the own wing stands const dx = e.x - b.x; const dy = e.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < bd) { bd = d2; bx = e.x; by = e.y; } } if (player && b.hostileToPlayer !== false) { const dx = player.x - b.x; const dy = player.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < bd) { bd = d2; bx = player.x; by = player.y; } } if (bd === Infinity || bd > h.range * h.range) continue; if (!b.body) continue; const vx = b.body.velocity.x; const vy = b.body.velocity.y; const sp = Math.hypot(vx, vy); if (sp <= 0) continue; const cur = Math.atan2(vy, vx); const want = Math.atan2(by - b.y, bx - b.x); let dA = Phaser.Math.Angle.Wrap(want - cur); const maxTurn = h.turn * dt; dA = Math.max(-maxTurn, Math.min(maxTurn, dA)); const na = cur + dA; b.body.velocity.x = Math.cos(na) * sp; b.body.velocity.y = Math.sin(na) * sp; b.angle0 = na; b.rotation = na; if (typeof b._syncCore === 'function') b._syncCore(); } } // ------------------------------------------------------------- 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) — 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). The ALL-HOSTILE rule: your tracers // pop any court, and a rival wing's tracers pop THEIR court (the // bait is built around every enemy, not just the ship) — the own // wing's bolts pass through their own phantoms (no friendly fire // on a hologram), and a classic (unclaimed) court only your tracers // can pop (the old behavior). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead) continue; if (b.owner !== 'player' && !(b.owner === 'enemy' && b.factionId)) continue; for (const il of this.illusions) { if (il.dead) continue; if (b.owner === 'enemy' && (!il.factionId || il.factionId === b.factionId)) 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..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; } }