import { config } from '../config/Config.js'; import { EnemyShip } from './EnemyShip.js'; import { enemyTypeConfig } from './EnemyTypes.js'; import { Bullet } from './Bullet.js'; import { Fx, FX_COLORS, settleCraftTint } from './Fx.js'; import { applyDamage, inView, pickTarget } from './Combat.js'; import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js'; import { XpManager } from './XpManager.js'; import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js'; import { combatShake } from './CamShake.js'; import { normalizeWaves, WaveRunner } from './Waves.js'; const TAU = Math.PI * 2; /** * COMBAT AS A SYSTEM — the portable half of the combat sandbox, built to * live INSIDE an existing scene (the main game: js/scenes/GameScene.js) * instead of owning one (the range: js/combat/CombatScene.js, which * inlines the same rules for its fixed arena). * * It owns, per scene: * - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*) * - every tracer (Bullet — one-shot, life-limited) * - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn, * + the per-level bump from data/progression.json → reward) * - the player's ENERGY (→ energy: the skills' resource — they spend it later) * - the XP BANK (XpManager — the combat level + experience, the * curve in data/progression.json; a kill grants the * hostile's type `xp`, a level-up's beat — the armor * bump, the burst, the scene's onLevelUp — rides the * manager's 'levelup' EVENT; the skill layer subscribes * to the same one later) * - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the * ship fires at the NEAREST one — the mouse steers, * the cannon tracks; the cannon's numbers are → player.weapon) * - the ON-SCREEN RULE (a hostile must be VISUALLY ON SCREEN to fire or * be fired upon: an off-screen craft holds its lance, * the cannon won't engage it, and its tracers pass * through — it enters the fight only once it has * flown into view; pure geometry in Combat.js → inView) * - the collision pass (swept bullet hits + rams — the main game's * hand-rolled circle-check style, no arcade colliders) * - the WAVE RUNS (Waves.js state machine — quest-step combat: an * ordered list of 'rolling' / 'fixed' waves, each a * roster of enemyTypes with a total force. startWaves * runs the script — lead, wave, gap, wave, … — and * the kills feed the rolling replacements. The QUEST * HOOK THAT FIRES startWaves LANDS LATER; until then * the console drives it (GameScene → __ORBIT_COMBAT__.waves)) * * The 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) */ 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.enemies = []; this.bullets = []; // The heat-seeker roster (js/combat/Weapons.js steps + hits them; the // missiles themselves live in js/combat/Missile.js). this.missiles = []; this.kills = 0; // The wave run in flight (Waves.js — null when no script is running). this.waveRun = null; this._spawnSerial = 0; // the orbit-dir alternation counter (pack + waves) // The player's life (playerShip.* — both contexts share it). The BASE // armor is playerShip's; the per-level bump (data/progression.json → // reward) stacks on top of it, level by level — the placeholder // toughness until the skill layer lands (a passive can override or // stack these later). const p = config.section('combat.playerShip', {}); this.baseHull = Math.max(1, Number(p.hull) || 100); this.baseShields = Math.max(0, Number(p.shields) || 0); const rw = config.section('progression.reward', {}); this.hullPerLevel = Math.max(0, Number(rw.hullPerLevel) || 0); this.shieldPerLevel = Math.max(0, Number(rw.shieldPerLevel) || 0); this.fullHealOnLevelUp = rw.fullHealOnLevelUp !== false; // The XP bank (js/combat/XpManager.js — the curve is // data/progression.json; each hostile carries its own type's value, // combat.enemy.xp / the type's override). A scene may hand over a // SHARED manager (o.xp — the main game's save state, restored from // the ledger before the system builds); the manager is pure data, // so it outlives any single scene. this.xp = o.xp ?? new XpManager(); // A level's beat: the armor bump + the moment (the burst + the voice), // then the scene's callback (the kill feed's line). The skill layer // will subscribe to the manager's 'levelup' event itself — this is // the system's share of it. this._levelUnsub = this.xp.on('levelup', (ev) => this._onLevelUp(ev)); // Apply the CURRENT level's armor (a restored ledger's level — a load // fires no events, so the bump lands here, not on the event), then a // fresh scene is a fresh ship (full armor, whatever the level). this._applyArmorToLevel(); this.hull = this.maxHull; this.shields = this.maxShields; this.playerRespawn = p.respawn !== false; this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000; this.playerDown = false; this.respawnAt = 0; this.lastHitAt = -1e9; // ENERGY — the skill resource (→ energy). Skills (keys 1-8, the bar's // middle seats) spend it; for now it just sits full, and the bar // shows it. const en = config.section('combat.energy', {}); this.maxEnergy = Math.max(0, Number(en.max) || 100); this.energy = this.maxEnergy; this.energyRegen = Math.max(0, Number(en.regen) || 0); // Main-game combat (→ game). const g = config.section('combat.game', {}); this.spawnCount = Math.max(0, Number(g.spawnCount) || 3); this.spawnRadius = Math.max(60, Number(g.spawnRadius) || 340); this.autoFire = { enabled: g.autoFire?.enabled !== false, range: Math.max(0, Number(g.autoFire?.range) || 800), }; // The rams' restitution (the range's arena bounce reads as a general // "what a collision keeps of the speed" fraction — combat.arena.bounce). this.restitution = Math.min(1, Math.max(0, Number(config.get('combat.arena.bounce', 0.9)) || 0.9)); this.ramUntil = 0; this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp this.fx = new Fx(scene); // The whiteness-purge roster: the purge (Fx.purgeCraftTints, every // ~250 ms) must cover the WHOLE pack — a craft that somehow ends up // holding the white hit-flash state is re-asserted back to its // normal colors no matter how the stick got there. this.fx.purgeRoster = () => [this.ship, ...this.enemies]; this._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), ); } /** Fire the state change at the scene (bar swap) when it flips. */ _setActive() { const a = this.active; if (a === this._active) return; this._active = a; if (this.onActiveChange) this.onActiveChange(a); } /** * The distance from the ship at which a fresh hostile materializes: * OFF-SCREEN, so it flies into view and attacks instead of popping in. * That is the furthest of the spawn band (game.spawnRadius) and the * camera's visible edge + the fly-in lead (game.spawnLead px past the * worldView — already in world px, zoom included). A scene without a * camera (the headless tests) gets the plain band. */ _offscreenRadius() { const cam = this.scene?.cameras?.main; const lead = Math.max(0, Number(config.get('game.spawnLead', 140)) || 0); if (cam && cam.worldView && cam.worldView.width > 0) { const halfDiag = Math.hypot(cam.worldView.width, cam.worldView.height) / 2; return Math.max(this.spawnRadius, halfDiag + lead); } return this.spawnRadius; } /** * The camera's visible world rect (world px, zoom included) — the * surface the ON-SCREEN RULE tests against. Null when the scene has no * camera (the headless tests): then nothing is off-screen and the rule * is a no-op. */ visibleView() { const cam = this.scene?.cameras?.main; const v = cam && cam.worldView ? cam.worldView : null; return v && v.width > 0 && v.height > 0 ? v : null; } /** * The ON-SCREEN RULE (the pure geometry is Combat.js → inView): a * hostile is "on screen" while ANY part of its visual disc (its * hitRadius, or half its hull length — whichever is wider) still * touches the camera's visible rect. Off-screen hostiles are OUT of * the fight: they hold their lances, the cannon won't engage them, and * their tracers pass through (they re-enter the moment they fly in). */ craftVisible(e) { if (!e) return false; const r = Math.max(Number(e.hitRadius) || 0, (Number(e.size) || 0) * 0.5); return inView(e.x, e.y, r, this.visibleView()); } /** * Spawn the starting pack in a ring around (cx, cy) — the ?combat boot * calls this with the ship's position "when the player starts". The ring * sits OFF-SCREEN (_offscreenRadius): the pack streams in on its own * chase AI instead of materializing on the player. * @returns {EnemyShip[]} the fresh hostiles */ spawnPack(cx, cy) { const out = []; for (let i = 0; i < this.spawnCount; i++) { const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6; const r = this._offscreenRadius() * (0.9 + Math.random() * 0.2); out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i)); } return out; } /** * One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its * bearing. * @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base */ spawnEnemy(x, y, index, type = null) { const e = new EnemyShip(this.scene, x, y, index, enemyTypeConfig(type)); 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; } // ----------------------------------------------------------------- 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); return e; } /** * HALT the run in flight — the "the mark is down, stop sending the rest" * seam a bounty hunt uses after it kills its target. Unlike cancelWaves it * takes nothing out of the world: every live wave ship stays HOSTILE and * must be flown down, but nothing else deploys (the wave's unspent force is * dropped and no replacement follows) and none of them reform. When the last * one drops the run reads 'done' (waveStatus), which is what the scene waits * on before it completes the contract. * * @returns {object|null} the run that was halted (null when none / already over) */ haltWaves() { const run = this.waveRun; if (!run || run.state === 'done' || run.state === 'cancelled' || run.state === 'halted') return null; run.state = 'halted'; run.nextAt = null; for (const w of run.runner.waves) { w.queue.length = 0; // nothing more deploys, replacements included } for (const e of this.enemies) { if (e.wave && !e.destroyed) { e.respawn = false; // the survivors fight it out — no reform clock e._respawnPending = false; e.respawnAt = 0; } } this._setActive(); return run; } /** * The run's state step (the tail of update()): lead/gap → deploy once * the clock has run, live → hand a cleared wave over (the gap, or the * end of the script). The kills already landed in killEnemy (the rolling * replacements spawn there). * @param {number} time the scene clock (ms) */ _waveTick(time) { const run = this.waveRun; if (!run || run.state === 'done' || run.state === 'cancelled') return; const runner = run.runner; // HALTED (the mark went down): no deployments, no replacements — the run // is over when the ships still on screen go down with it. if (run.state === 'halted') { if (!this.enemies.some((e) => e.wave && !e.dead)) { run.state = 'done'; this._setActive(); } return; } // The current wave's force is spent → the gap (or the end of the run). if (run.state === 'live' && runner.waveCleared()) { if (run.onWaveClear) run.onWaveClear(runner.index, runner.current, this); if (runner.hasNext) { run.state = 'gap'; run.nextAt = time + runner.waves[runner.index + 1].gapMs; } else { run.state = 'done'; if (run.onComplete) run.onComplete(runner.status(), this); } this._setActive(); return; } // The clocks: the lead before wave 1, the gap between waves. if (run.state === 'lead' || run.state === 'gap') { if (run.nextAt == null) run.nextAt = time + run.lead; if (time < run.nextAt) return; const spawns = run.state === 'lead' ? runner.begin() : runner.next(); if (!spawns) { run.state = 'done'; if (run.onComplete) run.onComplete(runner.status(), this); this._setActive(); return; } for (const t of spawns) this._spawnWaveEnemy(t); run.state = 'live'; if (run.onWaveStart) run.onWaveStart(runner.index, runner.current, this); } } /** * Recompute the max armor for the current level (the base + the level's * per-level bump — data/progression.json → reward) and settle the live * armor: clamped to the new max, refilled when fullHealOnLevelUp (a * level-up's beat is a full heal; a restored ledger's level applies the * same way on build — the method is level-driven, so both paths share it). */ _applyArmorToLevel() { const level = this.xp ? this.xp.level : 0; this.maxHull = Math.max(1, this.baseHull + level * this.hullPerLevel); this.maxShields = Math.max(0, this.baseShields + level * this.shieldPerLevel); this.hull = Math.min(this.hull ?? this.maxHull, this.maxHull); this.shields = Math.min(this.shields ?? this.maxShields, this.maxShields); if (this.fullHealOnLevelUp) { this.hull = this.maxHull; this.shields = this.maxShields; } } /** * A level landed (the manager's 'levelup' event — one per level * crossed): the armor bump (the placeholder toughness — the skill layer * lands on the same event later), the moment (a cyan burst off the ship * + the LEVEL callout + the voice), and the scene's callback (the kill * feed's line — js/scenes/GameScene.js wires it). * @param {{from:number, to:number, source:string}} ev the event payload */ _onLevelUp(ev) { this._applyArmorToLevel(); if (this.ship) { this.fx.explode(this.ship.x, this.ship.y, FX_COLORS.respawn); this.fx.text( this.ship.x, this.ship.y - 44, `LEVEL ${this.xp.level}`, FX_COLORS.xp, 18, 900, 40, ); } combatSfx(this.scene, 'levelup'); if (this.onLevelUp) this.onLevelUp(this.xp.level, ev); } // ------------------------------------------------------------ the loop /** * Step the combat (call from the scene's update, after the ship). * @param {number} time game clock (ms — the scene's stepped clock) * @param {number} delta frame delta (ms) */ update(time, delta) { const dt = Math.min(delta, 64) / 1000; const player = this.playerDown ? null : this.ship; // 1) The pack: orbit AI + lances (a dead one is its reform clock). // The ON-SCREEN RULE: the AI may WANT to fire, but the lance only // leaves the barrel while the craft is (at least partly) on screen — // an off-screen hostile holds its fire until it flies into view. for (const e of this.enemies) { if (e.dead) { if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time); continue; } const wantsFire = e.update(time, delta, player); if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time); } // 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire). // The mouse steers the ship; the cannon tracks on its own — but // only at what's on screen (off-screen hostiles can't be fired // upon; the NEAREST counts only if it's in view). if ( player && this.autoFire.enabled && this.autoFire.range > 0 && time >= this.nextFireAt ) { const target = pickTarget( this.enemies, player.x, player.y, this.autoFire.range, (e) => this.craftVisible(e), ); if (target) this.firePlayerAt(player, target, time); } // 3) Tracers: age + cull (no walls here — the arena is the tether range). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || !b.active) { this.bullets.splice(i, 1); continue; } b.update(delta); if (b.dead) this.bullets.splice(i, 1); } // 3b) The heat-seekers (js/combat/Weapons.js): age the fuses + home on // the player while it stands (fly true after it falls); the fuses // end them. No walls here (the range clips its own). stepMissiles(this, delta, this.playerDown ? null : player); // 4) Collisions: your tracers × the pack, their lances × you, rams. this.collide(delta, player); // 5) The player's armor bookkeeping: shield + energy regen, respawn. if (!this.playerDown) { const p = config.section('combat.playerShip', {}); if (this.shields < this.maxShields && this.maxShields > 0) { const grace = (Number(p.shieldRegenDelay) || 4) * 1000; const rate = Math.max(0, Number(p.shieldRegenRate) || 14); if (time - this.lastHitAt >= grace) { this.shields = Math.min(this.maxShields, this.shields + rate * dt); } } if (this.energy < this.maxEnergy) { this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt); } } else if (this.playerRespawn && this.respawnAt > 0 && time >= this.respawnAt) { this.revivePlayer(time); } this.fx.update(delta); // 6) The WAVE RUN (Waves.js): lead/gap clocks, the cleared handoff. this._waveTick(time); } /** The nearest live hostile to (x, y), or null (the shared pickTarget rule). */ nearestEnemy(x, y) { return pickTarget(this.enemies, x, y); } /** * The CURRENT TARGET (the readability layer's lock, js/combat/CombatHud.js): * the hostile the auto-fire is on — the nearest VISIBLE one WITHIN * auto-fire range (the same pickTarget rule, ranged by game.autoFire.range * and narrowed by the on-screen rule — so the brackets sit on exactly the * ship the cannon can engage, never an off-screen one). */ currentTarget() { if (!this.ship) return null; return pickTarget( this.enemies, this.ship.x, this.ship.y, this.autoFire.range, (e) => this.craftVisible(e), ); } /** * RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's * start position settles AFTER create() (the tether barrier / a save * restore places it at the starting system's rim; the fallback spawn * is the origin), so a pack spawned around the create-time position * would sit kilometers off. Shifts every live enemy so the pack's * centroid lands on the ship (each enemy's ring angle is preserved). * A no-op once anchored (within 64px), with no ship, or with no live * enemies. The range doesn't need it (its ship never settles away) * — but the shared method is where the rule lives. * @returns {boolean} true if the pack moved */ resyncAroundShip() { if (!this.ship) return false; let cx = 0, cy = 0, n = 0; for (const e of this.enemies) { if (!e.dead) { cx += e.x; cy += e.y; n++; } } if (n === 0) return false; cx /= n; cy /= n; const dx = this.ship.x - cx; const dy = this.ship.y - cy; if (Math.hypot(dx, dy) < 64) return false; // already anchored for (const e of this.enemies) { if (e.dead) continue; e.x += dx; e.y += dy; } return true; } // --------------------------------------------------------------- firing /** * The ship's auto-fire at a hostile (the sandbox's manual cannon, minus * the key): one tracer from the nose, toward the target, spread-jittered. * The cooldown lives here (nextFireAt) — the weapon's numbers are * combat.player.weapon (shared with the range). */ firePlayerAt(player, target, time) { const wpn = config.section('combat.player.weapon', {}); this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000; const a = Math.atan2(target.y - player.y, target.x - player.x) + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0)); const nose = (Number(player.radius) || 12) + 8; const x = player.x + Math.cos(a) * nose; const y = player.y + Math.sin(a) * nose; this.spawnBullet(x, y, a, wpn, 'player'); this.fx.muzzle(x, y, a, wpn.color ?? '#00e5ff', Number(wpn.muzzle) || 46); combatSfx(this.scene, 'cannon_fire'); // the mass-cannon voice (the enemy lances keep the blaster ping) } /** * A hostile's weapon volley — the SHARED pattern dispatch (js/combat/Weapons.js) * both combat contexts run: the base 'lance' plus the new types' patterns * (spray / burst / rapid / ring / missile / combo — data/combat.json → * enemyTypes, weapon.pattern). */ fireEnemy(e, player, time) { return firePattern(this, e, player, e.wpn ?? {}); } 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; } } } } // 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); } } } // 2b) Heat-seekers × you (js/combat/Weapons.js): the missile's damage // lands from its incoming bearing; the hit blooms + consumes it. if (player) missilesHitPlayer(this, player, Number(player.radius) || 12, delta); // Rams (you × the pack) — push apart, bounce, damage both (grace-limited). if (player) { for (const e of this.enemies) { if (!e.dead) this.ramPair(player, e); } } } /** 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); } } // ----------------------------------------------------- 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'); 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); this._setActive(); } // ------------------------------------------------------------- the bar /** * A skill slot press (keys 1-8 / the bar's middle seats). The skills * land later — for now the seats are empty: the bar calls this, and * this is where one will spend energy and do the thing. * @param {number} index 0-7 (slot 1-8's index) */ cast(index) { // (the skill registry lands with the real combat port) void index; } /** A snapshot for the combat bar (shields/hull left, energy right) + * the XP row (the xp bank's state — level, the run toward the next, * the numbers). */ stats() { return { hull: this.hull, maxHull: this.maxHull, shields: this.shields, maxShields: this.maxShields, energy: this.energy, maxEnergy: this.maxEnergy, playerDown: this.playerDown, xp: this.xp ? this.xp.stats() : null, }; } /** Tear the combat down (scene destroy). */ destroy() { this.waveRun = null; if (this._levelUnsub) { this._levelUnsub(); this._levelUnsub = null; } for (const e of this.enemies) { this.fx.releaseCraft(e); // settle flashes before the craft goes e.destroy(); } for (const b of this.bullets) if (!b.dead) b.destroy(); this.enemies = []; this.bullets = []; this.fx.clear(); this.ship = null; this.onActiveChange = this.onPlayerDamage = this.onPlayerDown = this.onPlayerUp = this.onKill = this.onLevelUp = null; } }