orbit/js/combat/CombatSystem.js

784 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from './Fx.js';
import { applyDamage, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.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 player's ENERGY (→ energy: the skills' resource — they spend it 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 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: spawnPack(cx, cy) starts the fight (the ?combat boot does
* this around the ship when the player starts); 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
*/
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.enemies = [];
this.bullets = [];
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).
const p = config.section('combat.playerShip', {});
this.maxHull = Math.max(1, Number(p.hull) || 100);
this.maxShields = Math.max(0, Number(p.shields) || 0);
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);
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;
}
/**
* 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).
*
* 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) 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.
* @param {string} typeId the hostile type to send (a combat.enemyTypes id)
* @returns {EnemyShip} the fresh hostile (tagged `wave`)
*/
_spawnWaveEnemy(typeId) {
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 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
return e;
}
/**
* 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;
// 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);
}
}
// ------------------------------------------------------------ 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).
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.fireEnemy(e, player, time);
}
// 2) AUTO-FIRE: the nearest live hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own.
if (
player &&
this.autoFire.enabled &&
this.autoFire.range > 0 &&
time >= this.nextFireAt
) {
const target = this.nearestEnemy(player.x, player.y);
if (target && Math.hypot(target.x - player.x, target.y - player.y) <= this.autoFire.range) {
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);
}
// 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 one WITHIN auto-fire
* range (the same pickTarget rule, ranged by game.autoFire.range — so
* the brackets sit on exactly the ship the cannon is tracking).
*/
currentTarget() {
if (!this.ship) return null;
return pickTarget(this.enemies, this.ship.x, this.ship.y, this.autoFire.range);
}
/**
* 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.flash(x, y, wpn.color ?? '#00e5ff', 26, 110);
combatSfx(this.scene, 'fire');
}
/** A hostile's lance at the player (aimed at the ship, spread-jittered). */
fireEnemy(e, player, time) {
const wpn = e.wpn ?? {};
const toPlayer = Math.atan2(player.y - e.y, player.x - e.x);
const a = toPlayer + (Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
const nose = e.hitRadius + 10;
this.spawnBullet(
e.x + Math.cos(a) * nose,
e.y + Math.sin(a) * nose,
a,
wpn,
'enemy',
);
this.fx.flash(e.x + Math.cos(a) * nose, e.y + Math.sin(a) * nose, wpn.color ?? '#ff2d6f', 28, 100);
combatSfx(this.scene, 'fire', { vol: 0.45 }); // the same blaster voice, quieter
}
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,
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.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.flash(b.x, b.y, FX_COLORS[kind], 34, 130);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfx(this.scene, 'impact');
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)) {
this.damagePlayer(b.damage);
this.fx.flash(b.x, b.y, '#9db4ff', 30, 130);
if (b.consume()) this.bullets.splice(i, 1);
}
}
}
// 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;
this.fx.flash(midX, midY, '#ffffff', 46, 160);
const preShields = b.shields;
const ramSelf = b.ramDamageSelf ?? 0;
this.damagePlayer(b.ramDamage ?? 0); // 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. */
damagePlayer(n) {
if (this.playerDown) return;
this.lastHitAt = this._now();
const armor = { hull: this.hull, shields: this.shields };
const { dead, shieldHit, hullHit } = applyDamage(armor, n);
this.hull = armor.hull;
this.shields = armor.shields;
// The hit-flash reads the layer that took it (cyan = shields, orange = hull).
const kind = shieldHit > 0 ? 'shield' : 'hull';
if (this.ship) this.fx.flash(this.ship.x, this.ship.y, FX_COLORS[kind], 60, 140);
if (this.ship) 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');
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.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) 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;
this.kills += 1;
this.fx.explode(e.x, e.y, '#ff5d6f');
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). */
stats() {
return {
hull: this.hull,
maxHull: this.maxHull,
shields: this.shields,
maxShields: this.maxShields,
energy: this.energy,
maxEnergy: this.maxEnergy,
playerDown: this.playerDown,
};
}
/** Tear the combat down (scene destroy). */
destroy() {
this.waveRun = null;
for (const e of this.enemies) 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 = null;
}
}