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 } 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 { barrels as barrelLine, intercept } from './Aiming.js'; import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, dashPoint, hexToInt, radiusHits } 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'; 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 heat-seeker roster (js/combat/Weapons.js steps + hits them; the // missiles themselves live in js/combat/Missile.js). this.missiles = []; this.kills = 0; // The wave run in flight (Waves.js — null when no script is running). this.waveRun = null; this._spawnSerial = 0; // the orbit-dir alternation counter (pack + waves) // The player's life (playerShip.* — both contexts share it). The BASE // armor is playerShip's; the per-level bump (data/progression.json → // reward) stacks on top of it, level by level — the placeholder // toughness until the skill layer lands (a passive can override or // stack these later). const p = config.section('combat.playerShip', {}); this.baseHull = Math.max(1, Number(p.hull) || 100); this.baseShields = Math.max(0, Number(p.shields) || 0); const rw = config.section('progression.reward', {}); this.hullPerLevel = Math.max(0, Number(rw.hullPerLevel) || 0); this.shieldPerLevel = Math.max(0, Number(rw.shieldPerLevel) || 0); this.fullHealOnLevelUp = rw.fullHealOnLevelUp !== false; // The XP bank (js/combat/XpManager.js — the curve is // data/progression.json; each hostile carries its own type's value, // combat.enemy.xp / the type's override). A scene may hand over a // SHARED manager (o.xp — the main game's save state, restored from // the ledger before the system builds); the manager is pure data, // so it outlives any single scene. this.xp = o.xp ?? new XpManager(); // A level's beat: the armor bump + the moment (the burst + the voice), // then the scene's callback (the kill feed's line). The skill layer // will subscribe to the manager's 'levelup' event itself — this is // the system's share of it. this._levelUnsub = this.xp.on('levelup', (ev) => this._onLevelUp(ev)); // Apply the CURRENT level's armor (a restored ledger's level — a load // fires no events, so the bump lands here, not on the event), then a // fresh scene is a fresh ship (full armor, whatever the level). this._applyArmorToLevel(); this.hull = this.maxHull; this.shields = this.maxShields; this.playerRespawn = p.respawn !== false; this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000; this.playerDown = false; this.respawnAt = 0; this.lastHitAt = -1e9; // ENERGY — the skill resource (→ energy). Skills (keys 1-8, the bar's // middle seats) spend it; for now it just sits full, and the bar // shows it. const en = config.section('combat.energy', {}); this.maxEnergy = Math.max(0, Number(en.max) || 100); this.energy = this.maxEnergy; this.energyRegen = Math.max(0, Number(en.regen) || 0); // Main-game combat (→ game). const g = config.section('combat.game', {}); this.spawnCount = Math.max(0, Number(g.spawnCount) || 3); this.spawnRadius = Math.max(60, Number(g.spawnRadius) || 340); this.autoFire = { enabled: g.autoFire?.enabled !== false, range: Math.max(0, Number(g.autoFire?.range) || 800), }; // The rams' restitution (the range's arena bounce reads as a general // "what a collision keeps of the speed" fraction — combat.arena.bounce). this.restitution = Math.min(1, Math.max(0, Number(config.get('combat.arena.bounce', 0.9)) || 0.9)); this.ramUntil = 0; this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp // 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; this.fx = new Fx(scene); // The whiteness-purge roster: the purge (Fx.purgeCraftTints, every // ~250 ms) must cover the WHOLE pack — a craft that somehow ends up // holding the white hit-flash state is re-asserted back to its // normal colors no matter how the stick got there. this.fx.purgeRoster = () => [this.ship, ...this.enemies, ...this.allies]; this._active = false; } // ------------------------------------------------------------- the pack /** * Combat is live when a hostile is up (or one is on its reform clock), * or a WAVE RUN is in flight (the lead + the gaps between waves keep the * combat bar up — the fight is on even when the sky is clear). */ get active() { const run = this.waveRun; if (run && run.state !== 'done' && run.state !== 'cancelled') return true; return ( this.enemies.some((e) => !e.dead || (e._respawnPending && e.respawnAt > 0)) || // A live escort keeps the fight up too (its lances are live even // when the pack is spent — the encounter ends when it's OFF). A // PERSISTENT ally (the escort contract's transport — // js/combat/TransportShip.js) does NOT: it stands for the life of // the contract, so it can't hold the jump gate / the landing door // open — the fight is over the moment the last pirate is down. this.allies.some((a) => !a.persistent && !a.dead && !a.gone && !a._removed) ); } /** * Is the LIVE roster a NEUTRAL-ONLY encounter? — at least one escort in, * zero hostiles anywhere (not even pending respawn). This is the gate * for the neutral soundtrack + the pilot's scan comms: the moment the * last pirate dies with escorts still escorting, it stands. */ _neutralOnly() { const enemiesLive = this.enemies.some((e) => e && !e.dead); const alliesLive = this.allies.some((a) => a && !a.persistent && !a.dead && !a.gone && !a._removed); return alliesLive && !enemiesLive; } /** * Fire the state change at the scene (bar swap) when it flips — now * WITH the neutral-only flag, so a neutral swap (active → active, or * active → over) re-runs the scene's music seam (combat ↔ neutral ↔ * deep-space) even when the plain active flag didn't move (last pirate * down, escorts still escorting). */ _setActive() { const a = this.active; const n = a && this._neutralOnly(); if (a === this._active && n === this._neutral) return; this._active = a; this._neutral = n; if (this.onActiveChange) this.onActiveChange(a, n); } /** * The distance from the ship at which a fresh hostile materializes: * OFF-SCREEN, so it flies into view and attacks instead of popping in. * That is the furthest of the spawn band (game.spawnRadius) and the * camera's visible edge + the fly-in lead (game.spawnLead px past the * worldView — already in world px, zoom included). A scene without a * camera (the headless tests) gets the plain band. */ _offscreenRadius() { const cam = this.scene?.cameras?.main; const lead = Math.max(0, Number(config.get('game.spawnLead', 140)) || 0); if (cam && cam.worldView && cam.worldView.width > 0) { const halfDiag = Math.hypot(cam.worldView.width, cam.worldView.height) / 2; return Math.max(this.spawnRadius, halfDiag + lead); } return this.spawnRadius; } /** * The camera's visible world rect (world px, zoom included) — the * surface the ON-SCREEN RULE tests against. Null when the scene has no * camera (the headless tests): then nothing is off-screen and the rule * is a no-op. */ visibleView() { const cam = this.scene?.cameras?.main; const v = cam && cam.worldView ? cam.worldView : null; return v && v.width > 0 && v.height > 0 ? v : null; } /** * The ON-SCREEN RULE (the pure geometry is Combat.js → inView): a * hostile is "on screen" while ANY part of its visual disc (its * hitRadius, or half its hull length — whichever is wider) still * touches the camera's visible rect. Off-screen hostiles are OUT of * the fight: they hold their lances, the cannon won't engage them, and * their tracers pass through (they re-enter the moment they fly in). */ craftVisible(e) { if (!e) return false; const r = Math.max(Number(e.hitRadius) || 0, (Number(e.size) || 0) * 0.5); return inView(e.x, e.y, r, this.visibleView()); } /** * Spawn the starting pack in a ring around (cx, cy) — the ?combat boot * calls this with the ship's position "when the player starts". The ring * sits OFF-SCREEN (_offscreenRadius): the pack streams in on its own * chase AI instead of materializing on the player. * @returns {EnemyShip[]} the fresh hostiles */ spawnPack(cx, cy) { const out = []; for (let i = 0; i < this.spawnCount; i++) { const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6; const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2); out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i)); } return out; } /** * One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its * bearing. * @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base */ spawnEnemy(x, y, index, type = null) { const cfg = enemyTypeConfig(type); const e = new EnemyShip(this.scene, x, y, index, cfg); // The comm rolls' config (data/combat.json → enemyTypes..comms — // the pirate's lines; null for a type without one, then every roll is // a no-op). The roll's marks ride the ship (the comm section below). e.comms = cfg.comms && typeof cfg.comms === 'object' ? cfg.comms : null; e._commedAttack = false; // an attack line already fired (the rolls don't stack) e._wasOnScreen = false; // the on-screen roll's edge (fires once, on entry) e.setDepth(10); const px = this.ship ? this.ship.x : x; const py = this.ship ? this.ship.y : y; e.orbitAngle = Math.atan2(y - py, x - px); e.nextFireAt = 1000; // a brief non-shooting grace (ms clock) this.enemies.push(e); this._setActive(); return e; } /** * One NEUTRAL ESCORT (data/combat.json → neutralTypes — frames 20–27 * of the enemy sheet, the player-side roster): materializes * off-screen (the caller places it — the encounter director spawns it * in the _offscreenRadius band), then streams in on its own escort AI * (js/combat/NeutralShip.js). * * `o.escortMs` — this ship's escort window (ms); omitted = a per-ship * roll inside neutral.escortMin..escortMax ("each neutral ship flies * near the player for a random 10–15 s"); `o.viewFn` overrides the * break-off cull surface (headless tests). * @returns {NeutralShip} */ spawnAlly(x, y, index, type = null, o = {}) { const cfg = neutralTypeConfig(type); const base = config.section('combat.neutral', {}); const emin = Math.max(1, Number(base.escortMin) || 10); const emax = Math.max(emin, Number(base.escortMax) || 15); const 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); // 1) The pack: orbit AI + lances (a dead one is its reform clock). // The ON-SCREEN RULE: the AI may WANT to fire, but the lance only // leaves the barrel while the craft is (at least partly) on screen — // an off-screen hostile holds its fire until it flies into view. for (const e of this.enemies) { if (e.dead) { if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time); continue; } // THE TARGET (pickEnemyTarget): the player — or, in a mixed // encounter, the nearest in-weapon-range escort when the player // isn't (the escort draws aggro; a pirates-only fight resolves to // exactly the classic behavior). const target = this.pickEnemyTarget(e, player); const wantsFire = e.update(time, delta, target); if (wantsFire && target && this.craftVisible(e)) this.fireEnemy(e, target, time); // THE ATTACK COMM (the type's comms.attack — data/combat.json): the // ON-SCREEN roll (50%) fires once the craft first flies into view — // unless its arrival roll already commed it (a pirate never // double-comms; the marks ride the ship). if (e.comms?.attack && !e._commedAttack) { const vis = this.craftVisible(e); if (vis && !e._wasOnScreen) this._commAttackRoll(e, e.comms.attack.onScreenChance); e._wasOnScreen = vis; } } // 1b) NEUTRAL ESCORTS (js/combat/NeutralShip.js): escort/engage/ // break AI on their own clocks. The on-screen rule gates their // lances exactly like the pack's (an off-screen escort holds its // fire until it flies into view), and their target is always a // pirate — the player is never aimed at. for (const a of this.allies) { if (a.dead) continue; const r = a.update(time, delta, player, this.enemies); if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) { this.fireAlly(a, r.target, time); } // 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(); } // 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire). // The mouse steers the ship; the cannon tracks on its own — but // only at what's on screen (off-screen hostiles can't be fired // upon; the NEAREST counts only if it's in view). if ( player && this.autoFire.enabled && this.autoFire.range > 0 && time >= this.nextFireAt ) { const target = pickTarget( this.enemies, player.x, player.y, this.autoFire.range, (e) => this.craftVisible(e), ); if (target) this.firePlayerAt(player, target, time); } // 3) Tracers: age + cull (no walls here — the arena is the tether range). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || !b.active) { this.bullets.splice(i, 1); continue; } b.update(delta); if (b.dead) this.bullets.splice(i, 1); } // 3b) The heat-seekers (js/combat/Weapons.js): age the fuses + home on // the player while it stands (fly true after it falls); the fuses // end them. No walls here (the range clips its own). stepMissiles(this, delta, this.playerDown ? null : player); // 4) Collisions: your tracers × the pack, their lances × you, rams. this.collide(delta, player); // 5) The player's armor bookkeeping: shield + energy regen, respawn. if (!this.playerDown) { const p = config.section('combat.playerShip', {}); if (this.shields < this.maxShields && this.maxShields > 0) { const grace = (Number(p.shieldRegenDelay) || 4) * 1000; const rate = Math.max(0, Number(p.shieldRegenRate) || 14); if (time - this.lastHitAt >= grace) { this.shields = Math.min(this.maxShields, this.shields + rate * dt); } } if (this.energy < this.maxEnergy) { this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt); } } else if (this.playerRespawn && this.respawnAt > 0 && time >= this.respawnAt) { // (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 hostile to (x, y), or null (the shared pickTarget rule). */ nearestEnemy(x, y) { return pickTarget(this.enemies, x, y); } /** * The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js): * the hostile the auto-fire is on — the nearest VISIBLE one WITHIN * auto-fire range (the same pickTarget rule, ranged by game.autoFire.range * and narrowed by the on-screen rule — so the brackets sit on exactly the * ship the cannon can engage, never an off-screen one). */ currentTarget() { if (!this.ship) return null; return pickTarget( this.enemies, this.ship.x, this.ship.y, this.autoFire.range, (e) => this.craftVisible(e), ); } /** * A pirate's target this frame (the mixed-encounter rule): the * PLAYER while they're inside the pirate's weapon range (a * pirates-only fight — or the player in range — resolves exactly to * the classic chase); else the NEAREST live escort within that range * (a neutral ship draws aggro when the pirate can't reach the * player); else the player (the classic orbit — the fire gate keeps * it honest until in range). * @returns {object|null} the craft to orbit + fire at (null = player down) */ pickEnemyTarget(e, player) { if (!player) return null; const range = Number((e.wpn && e.wpn.range) || 0); const inRange = (o) => range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true; if (inRange(player)) return player; let best = null; let bestD = Infinity; for (const a of this.allies) { if (a.dead || !inRange(a)) continue; const d2 = (a.x - e.x) ** 2 + (a.y - e.y) ** 2; if (d2 < bestD) { bestD = d2; best = a; } } return best ?? player; } /** * RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's * start position settles AFTER create() (the tether barrier / a save * restore places it at the starting system's rim; the fallback spawn * is the origin), so a pack spawned around the create-time position * would sit kilometers off. Shifts every live enemy so the pack's * centroid lands on the ship (each enemy's ring angle is preserved). * A no-op once anchored (within 64px), with no ship, or with no live * enemies. The range doesn't need it (its ship never settles away) * — but the shared method is where the rule lives. * @returns {boolean} true if the pack moved */ resyncAroundShip() { if (!this.ship) return false; let cx = 0, cy = 0, n = 0; for (const e of this.enemies) { if (!e.dead) { cx += e.x; cy += e.y; n++; } } if (n === 0) return false; cx /= n; cy /= n; const dx = this.ship.x - cx; const dy = this.ship.y - cy; if (Math.hypot(dx, dy) < 64) return false; // already anchored for (const e of this.enemies) { if (e.dead) continue; e.x += dx; e.y += dy; } return true; } // --------------------------------------------------------------- firing /** * The ship's auto-fire at a hostile (the sandbox's manual cannon, minus * the key): one tracer from the nose, toward the target, spread-jittered. * The cooldown lives here (nextFireAt) — the weapon's numbers are * combat.player.weapon (shared with the range). */ firePlayerAt(player, target, time) { const wpn = config.section('combat.player.weapon', {}); // 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) { return firePattern(this, e, player, e.wpn ?? {}); } /** * A neutral escort's volley — the same shared pattern dispatch * (js/combat/Weapons.js) as the hostiles, except the bolts/seekers * carry the 'ally' owner: they hit the pack (the kill still banks the * pirate's XP through killEnemy), never the player (the collide * passes + Weapons.missilesHitPlayer skip them), and the player's * auto-fire never targets the escort (a different roster). */ fireAlly(a, target, time) { return firePattern(this, a, target, { ...(a.wpn ?? {}), owner: 'ally' }); } /** * The re-home seam for escort seekers (Weapons.stepMissiles): the * nearest live pirate to the missile, or null (fly straight — the * fuse ends it). */ allyMissileTarget(m) { return pickTarget(this.enemies, m.x, m.y); } spawnBullet(x, y, angle, wpn, owner) { const b = new Bullet(this.scene, { x, y, angle, speed: Number(wpn.bulletSpeed) || 600, damage: Number(wpn.damage) || 8, life: Number(wpn.bulletLife) || 1.2, color: wpn.color ?? '#ffffff', size: Number(wpn.size) || 16, boltLength: Number(wpn.boltLength) || 0, // 0 = Bullet's default (size × 3) boltWidth: Number(wpn.boltWidth) || 0, owner, }); b.setDepth(20); this.bullets.push(b); return b; } // ------------------------------------------------------------- collisions /** * Swept hit check (the sandbox's anti-tunneling rule): tracers run * ~700 px/s — at 30 fps that's ~23 px/frame, past a ~25 px combined * radius, so a point check can tunnel. Sample the segment the bullet * flew this frame (end, middle, start) against the target circle. */ hitsSwept(b, other, otherRadius, dtMs) { const r = b.radius + otherRadius; const r2 = r * r; const vx = b.body ? b.body.velocity.x : 0; const vy = b.body ? b.body.velocity.y : 0; const dt = Math.min(dtMs, 64) / 1000; for (let f = 1; f >= 0; f -= 0.5) { const dx = other.x - (b.x - vx * dt * f); const dy = other.y - (b.y - vy * dt * f); if (dx * dx + dy * dy < r2) return true; } return false; } /** The collision pass: your tracers × the pack, their lances × you, rams. */ collide(delta, player) { // Your tracers × hostiles. if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'player') continue; for (const e of this.enemies) { if (e.dead) continue; if (!this.craftVisible(e)) continue; // off-screen: can't be hit if (this.hitsSwept(b, e, e.hitRadius, delta)) { const preShields = e.shields; const killed = e.damage(b.damage); const kind = preShields > 0 ? 'shield' : 'hull'; // THE IMPACT READ (js/combat/Fx.js → enemyHit): core flash, // sparks along the bolt's travel, the craft's white flash, // and the layer's cue (a cyan shield arc / orange shards). this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage); this.fx.damage(b.x, b.y, b.damage, kind); combatSfx(this.scene, 'cannon_hit'); // the mass-cannon hit voice (the shield/hull cues ride on) combatSfxEnemyHit(this.scene, preShields, b.damage); if (killed) this.killEnemy(e, this._now()); if (b.consume()) this.bullets.splice(i, 1); break; } } } } // Your escorts' tracers × the pack (the neutral ships' patterns — // the same hit read as your own; the kill still banks the pirate's // XP through the one grant). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'ally') continue; for (const e of this.enemies) { if (e.dead) continue; if (!this.craftVisible(e)) continue; if (this.hitsSwept(b, e, e.hitRadius, delta)) { const preShields = e.shields; const killed = e.damage(b.damage); const kind = preShields > 0 ? 'shield' : 'hull'; this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage); this.fx.damage(b.x, b.y, b.damage, kind); combatSfxEnemyHit(this.scene, preShields, b.damage); if (killed) this.killEnemy(e, this._now()); if (b.consume()) this.bullets.splice(i, 1); break; } } } // Their lances × you. if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'enemy') continue; if (this.hitsSwept(b, player, Number(player.radius) || 12, delta)) { // The shield was struck from the laser's incoming bearing (the // side facing its source — the travel's opposite). this.damagePlayer(b.damage, { x: b.x, y: b.y, bearing: b.angle0 + Math.PI }); if (b.consume()) this.bullets.splice(i, 1); } } } // Their lances × your escorts (mixed fight: pirate bolts can drop // the neutral ships — the escort draws fire off the player). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'enemy') continue; for (const a of this.allies) { if (a.dead || !this.craftVisible(a)) continue; if (this.hitsSwept(b, a, a.hitRadius, delta)) { const preShields = a.shields; const killed = a.damage(b.damage); const kind = preShields > 0 ? 'shield' : 'hull'; this.fx.enemyHit(a, b.x, b.y, b.angle0, kind, b.damage); this.fx.damage(b.x, b.y, b.damage, kind); combatSfxEnemyHit(this.scene, preShields, b.damage); if (killed) this.killAlly(a, this._now()); if (b.consume()) this.bullets.splice(i, 1); break; } } } // 2b) Heat-seekers × you (js/combat/Weapons.js): the missile's damage // lands from its incoming bearing; the hit blooms + consumes it. // Escort seekers are excluded inside (no friendly fire). if (player) missilesHitPlayer(this, player, Number(player.radius) || 12, delta); // Their heat-seekers × your escorts (mixed fight: a pirate seeker // can drop a neutral ship). this.missilesHitAllies(delta); // Your escorts' heat-seekers × the pack (the escorts' missile // patterns — one kill path, the XP still lands). this.missilesHitEnemies(delta); // Rams (you × the pack) — push apart, bounce, damage both (grace-limited). // 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) { if (!e.dead) this.ramPair(player, e); } } } /** * Hostile seekers × live escorts (a mixed fight: a pirate seeker can * drop a neutral ship) — the hit is real damage, the kill is * killAlly (no XP — the bounty is the pirate's; no comms — the * neutrals don't talk). */ missilesHitAllies(delta) { const ms = this.missiles; if (!ms || ms.length === 0 || this.allies.length === 0) return; for (let i = ms.length - 1; i >= 0; i--) { const m = ms[i]; if (m.dead) { ms.splice(i, 1); continue; } if (m.owner === 'ally') continue; for (const a of this.allies) { if (a.dead || !this.craftVisible(a)) continue; if (this.hitsSwept(m, a, a.hitRadius, delta)) { const preShields = a.shields; const killed = a.damage(m.damage); const kind = preShields > 0 ? 'shield' : 'hull'; this.fx.enemyHit(a, m.x, m.y, m.heading, kind, m.damage); this.fx.damage(m.x, m.y, m.damage, kind); combatSfxEnemyHit(this.scene, preShields, m.damage); if (killed) this.killAlly(a, this._now()); if (m.consume()) ms.splice(i, 1); break; } } } } /** Escort seekers × the pack — the same hit read as the player's. */ missilesHitEnemies(delta) { const ms = this.missiles; if (!ms || ms.length === 0 || this.enemies.length === 0) return; for (let i = ms.length - 1; i >= 0; i--) { const m = ms[i]; if (m.dead) { ms.splice(i, 1); continue; } if (m.owner !== 'ally') continue; for (const e of this.enemies) { if (e.dead) continue; if (!this.craftVisible(e)) continue; if (this.hitsSwept(m, e, e.hitRadius, delta)) { const preShields = e.shields; const killed = e.damage(m.damage); const kind = preShields > 0 ? 'shield' : 'hull'; this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage); this.fx.damage(m.x, m.y, m.damage, kind); combatSfxEnemyHit(this.scene, preShields, m.damage); if (killed) this.killEnemy(e, this._now()); if (m.consume()) ms.splice(i, 1); break; } } } } /** The scene's clock (ms) — used for the kill bookkeeping's timestamps. */ _now() { return this.scene.time?.now ?? Date.now(); } /** * A ship-ship collision: push apart, exchange normal velocity (bounce), * and deal ram damage to BOTH (grace-limited — the hostile's own type * config: combat.enemy.ram* or the type's override). */ ramPair(a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy); const gap = (Number(a.radius) || 12) + b.hitRadius; if (d >= gap || d <= 0) return; const nx = dx / d; const ny = dy / d; const push = (gap - d) / 2; a.x -= nx * push; a.y -= ny * push; b.x += nx * push; b.y += ny * push; if (a.body && b.body) { const avx = a.body.velocity.x; const avy = a.body.velocity.y; const rvx = b.body.velocity.x - avx; const rvy = b.body.velocity.y - avy; const vn = rvx * nx + rvy * ny; if (vn < 0) { const j = (-(1 + this.restitution) * vn) / 2; // equal-mass impulse a.body.velocity.x = avx - j * nx; a.body.velocity.y = avy - j * ny; b.body.velocity.x += j * nx; b.body.velocity.y += j * ny; } } const t = this._now(); if (t >= this.ramUntil) { this.ramUntil = t + (b.ramGraceMs ?? 800); const midX = (a.x + b.x) / 2; const midY = (a.y + b.y) / 2; const bearing = Math.atan2(b.y - a.y, b.x - a.x); // the side that faced the rammer this.fx.flash(midX, midY, 0xffffff, 46, 160); this.fx.sparks(midX, midY, FX_COLORS.hull, 8, 260, 520, bearing, 0.7); this.fx.sparks(midX, midY, 0xffffff, 4, 200, 380, bearing, 0.6); this.fx.craftFlash(b); const preShields = b.shields; const ramSelf = b.ramDamageSelf ?? 0; this.damagePlayer(b.ramDamage ?? 0, { bearing }); // impact + shield/hull this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull'); combatSfxEnemyHit(this.scene, preShields, ramSelf); const killed = b.damage(ramSelf); if (killed) this.killEnemy(b, t); } } /** * A neutral escort is down (a pirate's bolt/seeker): the kill read * and the pull-out. No XP (a lost escort is not a bounty — the bank * is the pirate's), no comms (the neutrals don't talk), no respawn * (the encounter's force is spent). */ killAlly(a, time) { if (a._killFx) return; a._killFx = true; this.fx.releaseCraft(a); this.fx.explode(a.x, a.y, '#37e0a1'); combatSfx(this.scene, 'kill'); a.setVisible(false); if (a.body) { a.body.velocity.x = 0; a.body.velocity.y = 0; } a._removed = true; // the update() cull collects it this._setActive(); } // ----------------------------------------------------- damage / lifecycle /** * Hit the player's armor (shields → hull). Kills when the hull breaks. * `o.bearing` (rad from the ship toward the threat — the laser's * incoming side, or the ram's heading) steers the shield read; * omitted (a bare call) defaults to straight up. */ damagePlayer(n, o = {}) { if (this.playerDown) return; this.lastHitAt = this._now(); const armor = { hull: this.hull, shields: this.shields }; const preShields = this.shields; const { dead, shieldHit, hullHit } = applyDamage(armor, n); this.hull = armor.hull; this.shields = armor.shields; const broke = preShields > 0 && this.shields <= 0; const kind = shieldHit > 0 ? 'shield' : 'hull'; const bearing = Number.isFinite(o.bearing) ? o.bearing : -Math.PI / 2; if (this.ship) { if (shieldHit > 0) { // THE SHIELD READ — the sphere struck from the bearing, the hit // region highlighted (js/combat/Fx.js → shieldImpact). this.fx.shieldImpact(this.ship, bearing, shieldHit); } if (broke) this.fx.shieldBreak(this.ship); // the collapse cue if (hullHit > 0) { // The armor taking it: a hot bloom + the craft's white flash. this.fx.flash(this.ship.x, this.ship.y, FX_COLORS.hull, 60, 140); this.fx.craftFlash(this.ship); } if (shieldHit > 0 || hullHit > 0) { this.fx.damage(this.ship.x, this.ship.y, n, kind); // the damage number (both contexts) } } // The player-hit voices (one place: lances AND rams funnel here). combatSfx(this.scene, 'impact'); if (shieldHit > 0) combatSfx(this.scene, 'shield'); if (hullHit > 0) combatSfx(this.scene, 'hull'); // The world kick (the UI pass stays still — js/combat/CamShake.js). combatShake(this.scene, broke ? 'break' : 'hit'); if (this.onPlayerDamage) this.onPlayerDamage(n, { hull: this.hull, shields: this.shields, shieldHit, hullHit }); if (dead) this.killPlayer(); } /** The player's hull broke: down (and reformed on the clock, if armed). */ killPlayer() { this.playerDown = true; this.hull = 0; if (this.ship) { this.fx.releaseCraft(this.ship); // out in normal colors, never mid-flash this.fx.down(this.ship.x, this.ship.y); // the DOWN cue (burst + hold) this.ship.stop(); this.ship.setVisible(false); } combatSfx(this.scene, 'down'); if (this.onPlayerDown) this.onPlayerDown(); if (this.playerRespawn) { this.respawnAt = this._now() + this.playerRespawnDelay; } } /** The clock ran out — the player is back, full armor, in the same seat. */ revivePlayer(time) { this.playerDown = false; this.hull = this.maxHull; this.shields = this.maxShields; this.lastHitAt = 0; this.respawnAt = 0; if (this.ship) { settleCraftTint(this.ship); // never reappear holding a white hit-flash this.ship.setVisible(true); } if (this.ship) this.fx.respawn(this.ship.x, this.ship.y); // the REFORMED cue combatSfx(this.scene, 'respawn'); if (this.onPlayerUp) this.onPlayerUp(); } /** * A hostile went down: the kill FX + bookkeeping. WAVE SHIPS get spent * (their kill feeds the runner's rolling replacements — no respawn * clock); the rest keep their type's reform clock (combat.enemy.respawn * / the type's override). Idempotent via _killFx — damage() flips * e.dead first, so the dead flag can't be the "already counted" guard. */ killEnemy(e, time) { if (e._killFx) return; e._killFx = true; // Settle any pending white hit-flash NOW — the craft dies in its // normal colors, never mid-flash (the reuse seam's edge case). this.fx.releaseCraft(e); this.kills += 1; // COMBAT XP — the kill's reward (the hostile's own type's value — // combat.enemy.xp / the type's override; the curve is // data/progression.json). The +N floater lands on the wreckage, and a // crossed level's beat rides the manager's 'levelup' event (the armor // bump, the burst, the feed's line) — the kill path stays one grant. if (this.xp) { const gained = this.xp.grant(Number(e.xp) || 0, 'kill'); if (gained.gained > 0) this.fx.xp(e.x, e.y, gained.gained); } this.fx.explode(e.x, e.y, '#ff5d6f'); combatShake(this.scene, 'kill'); // the world kick (the bar stays still) combatSfx(this.scene, 'kill'); // THE DEFEAT COMM (data/combat.json → comms.defeat): a downed pirate // rolls its defeat line (20%) — the comm's beat rides the same kill // (once per death — killEnemy is idempotent via _killFx). this._commDefeatRoll(e); e.setVisible(false); if (e.body) { e.body.velocity.x = 0; e.body.velocity.y = 0; } const run = this.waveRun; const inWave = e.wave === true && run && run.state === 'live'; const respawn = !inWave && e.respawn !== false; e._respawnPending = respawn; e.respawnAt = respawn ? time + (e.respawnDelayMs ?? 0) : 0; if (inWave) { // The rolling replacement (a fixed wave sends nothing in). for (const t of run.runner.onKill()) this._spawnWaveEnemy(t); } if (this.onKill) this.onKill(e); this._setActive(); } /** The reform clock ran out — the craft is back on a ring edge. */ reviveEnemy(e, time) { e._killFx = false; e._respawnPending = false; e.respawnAt = 0; const a = Math.random() * TAU; const r = Math.max(120, e.orbitRadius * (0.8 + Math.random() * 0.5)); const px = this.ship ? this.ship.x : e.x; const py = this.ship ? this.ship.y : e.y; e.x = px + Math.cos(a) * r; e.y = py + Math.sin(a) * r; e.orbitAngle = a; e.revive(); e.nextFireAt = time + 900; e.setVisible(true); // A new life: the comm rolls re-arm (the pirate comes back with its // voice — the on-screen roll's edge resets with it). e._commedAttack = false; e._wasOnScreen = false; this._setActive(); } // ------------------------------------------------------ the comm rolls // // The comming type's VOICE (the comm's CONTENT — the clip, the random // speech, the line — is data/comms.json's, keyed by the roll's trigger // name: 'pirateAttack' / 'pirateDefeat', the senders 'pirate' / // 'pirate_defeat'). The chances are the type's (data/combat.json → // enemyTypes..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.enemies, 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 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 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; } }