Add enemy wave scripts, typed hostiles, and off-screen spawns

- Waves.js: pure wave runner (rolling/fixed) + CombatSystem.startWaves
  for quest-step combat; named scripts (probe/skirmish/raid) ship in
  combat.json, driven from the console until the quest hook lands
- EnemyTypes.js: named hostiles (raider/bomber) deep-merged onto the
  base craft, with per-type textures preloaded via enemySheetList()
- Hostiles now materialize off-screen (camera edge + game.spawnLead)
  and fly in on their chase AI instead of popping into view mid-fight
- Wave ships are run-owned: no self-respawn, kills feed rolling
  replacements, and the combat bar stays up through the lead and gaps
This commit is contained in:
Brian Fertig 2026-09-16 15:20:53 -06:00
parent b2cd5a2701
commit 11f315dbf0
8 changed files with 1016 additions and 77 deletions

View File

@ -3,9 +3,10 @@
"enabled": true, "enabled": true,
"game": { "game": {
"_comment": "COMBAT IN THE MAIN GAME (the ?combat boot). spawnCount = hostiles that appear around the ship when the player starts (spawnRadius px out — the real spawn SCHEDULES, places + waves, land with the port); autoFire = the ship's guns — while a hostile is within range the ship fires at the NEAREST one (the mouse steers, the cannon tracks; the cannon's own numbers are player.weapon below).", "_comment": "COMBAT IN THE MAIN GAME (the ?combat boot). spawnCount = hostiles that appear around the ship when the player starts. SPAWNING: hostiles materialize OFF-SCREEN — the furthest of the (spawnRadius band, the camera's visible edge + spawnLead) — and FLY IN on their own chase AI (standoff ring, then lances): nothing pops into view mid-fight. spawnRadius px = the minimum materialization band around the ship; spawnLead px = how far PAST the camera's visible edge they materialize (the fly-in distance — bigger = a longer stream across the screen). autoFire = the ship's guns — while a hostile is within range the ship fires at the NEAREST one (the mouse steers, the cannon tracks; the cannon's own numbers are player.weapon below).",
"spawnCount": 3, "spawnCount": 3,
"spawnRadius": 340, "spawnRadius": 340,
"spawnLead": 140,
"autoFire": { "autoFire": {
"enabled": true, "enabled": true,
"range": 800 "range": 800
@ -73,6 +74,49 @@
"respawnDelay": 2.5 "respawnDelay": 2.5
}, },
"enemyTypes": {
"_comment": "The hostile roster a WAVE (combat.waves.scripts) can mix — js/combat/EnemyTypes.js deep-merges a type's overrides onto the base `enemy` above, so a type declares only what differs. `raider` is the base craft under a named id (waves write it explicitly); `bomber` rides the same sheet on frame 1 — a slower, tougher, longer-reach striker. Add a craft here (a new `frame` or its own `texture` + stat overrides) and name it in a wave's roster; EnemyTypes.enemySheetList() is the preload seam for its textures.",
"raider": {
"frame": 0
},
"bomber": {
"frame": 1,
"speed": 110,
"turn": 2.2,
"hull": 90,
"shields": 20,
"weapon": {
"range": 720,
"cooldown": 1.8,
"bulletSpeed": 260,
"damage": 12
}
}
},
"waves": {
"_comment": "WAVE SCRIPTS — the quest-step combat (js/combat/Waves.js + CombatSystem.startWaves; the quest hook that FIRES one lands later, random encounters too — for now the console drives it: window.__ORBIT_COMBAT__.waves). A script is an ordered list of waves. KINDS: 'rolling' = maxConcurrent on screen at a time, each kill sending in replacePerKill more until the force is spent; 'fixed' = the whole force at once, no replacements until exhausted. COMPOSITION: `roster` = enemyTypes id → weight (the mix); `total` = the units deployed (default = the weight sum); `type` + `count` is the single-type shorthand. lead = s before wave 1; gap = s between waves (per-wave `gap` s overrides). The quests reference scripts BY NAME (e.g. {\"combat\": \"raid\"}) when the hook lands.",
"lead": 1.5,
"gap": 2.0,
"defaults": {
"maxConcurrent": 3,
"replacePerKill": 1
},
"scripts": {
"probe": [
{ "kind": "fixed", "type": "raider", "count": 3 }
],
"skirmish": [
{ "kind": "rolling", "roster": { "raider": 2 }, "total": 8, "maxConcurrent": 3, "replacePerKill": 1 },
{ "kind": "fixed", "roster": { "raider": 3, "bomber": 1 }, "total": 4 }
],
"raid": [
{ "kind": "fixed", "roster": { "raider": 4, "bomber": 2 }, "total": 6 },
{ "kind": "rolling", "roster": { "raider": 3, "bomber": 1 }, "total": 12, "maxConcurrent": 4, "replacePerKill": 1 }
]
}
},
"playerShip": { "playerShip": {
"_comment": "Your armor (both contexts — the real ship's stats.hullIntegrity/shields replace this when combat ships for real). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max. respawn = when your hull breaks you come back (respawnDelay s) instead of the run ending — a stand-in until the real death flow lands.", "_comment": "Your armor (both contexts — the real ship's stats.hullIntegrity/shields replace this when combat ships for real). shields absorb before the hull; after shieldRegenDelay s without a hit, shields regrow at shieldRegenRate/s up to their max. respawn = when your hull breaks you come back (respawnDelay s) instead of the run ending — a stand-in until the real death flow lands.",
"hull": 100, "hull": 100,

View File

@ -170,6 +170,12 @@ const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, off
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
); );
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href); const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { enemyTypeConfig, enemyTypeIds, enemySheetList } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { normalizeWaves, resolveRoster, WaveRunner } = await import(
pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href
);
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href); const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href); const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href); const { CombatHud } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatHud.js')).href);
@ -475,6 +481,54 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0); check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
} }
// ------------------------------------------------- off-screen spawning
{
// MAIN-GAME SPAWNS: with a camera, hostiles materialize PAST the
// visible edge (the worldView half-diagonal + game.spawnLead) and fly
// in on their chase AI — nothing pops into view.
const VW = 1280, VH = 720;
const scene = makeScene({ withSheet: true });
scene.cameras = { main: { worldView: { width: VW, height: VH } } };
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false;
const lead = Math.max(0, Number(config.get('game.spawnLead', 140)) || 0);
const halfDiag = Math.hypot(VW, VH) / 2;
const radius = combat._offscreenRadius();
check('spawn: the materialization radius is the camera edge + lead (≥ the band)',
approx(radius, Math.max(Number(config.get('game.spawnRadius', 340)), halfDiag + lead)));
const pack = combat.spawnPack(0, 0);
check('spawn: the pack materializes OFF-SCREEN (past the camera edge)',
pack.length === 3 && pack.every((e) => Math.hypot(e.x, e.y) > halfDiag));
check('spawn: the pack is still the spawn band wide (0.9–1.1 × radius)',
pack.every((e) => {
const d = Math.hypot(e.x, e.y);
return d >= radius * 0.9 - 1e-9 && d <= radius * 1.1 + 1e-9;
}));
// Wave ships get the same off-screen treatment.
combat.startWaves({ waves: [{ kind: 'fixed', roster: { raider: 1 }, total: 2 }], lead: 0, gap: 0 });
combat.update(1000, 16);
const wave = combat.enemies.filter((e) => e.wave && !e.dead);
check('spawn: wave ships materialize OFF-SCREEN too',
wave.length === 2 && wave.every((e) => Math.hypot(e.x, e.y) > halfDiag));
// The fly-in: one step of the chase AI closes in on the player side
// (velocity toward the ring point — the ships cross the screen).
const e0 = pack[0];
e0.update(1000, 16, { x: 0, y: 0 });
const closing = e0.body.velocity.x * -e0.x + e0.body.velocity.y * -e0.y; // v · toward-player
check('spawn: the fly-in closes in on the player (v · toward > 0)', closing > 0);
combat.destroy();
check('spawn: destroy still clears everything', combat.enemies.length === 0);
}
// ------------------------------------------------------------------ SFX // ------------------------------------------------------------------ SFX
{ {
@ -633,6 +687,202 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield'); hud2.flash('shield') === null && hud2.lastFlash.kind === 'shield');
} }
// ------------------------------------------------------------- enemy types
{
const base = enemyTypeConfig();
check('types: the base craft = combat.enemy (shipped)', base.hull === E('hull', 60) && base.frame === 0 && base.weapon.range === E('weapon.range', 560));
check('types: the shipped roster is raider + bomber', enemyTypeIds().join(',') === 'raider,bomber');
const raider = enemyTypeConfig('raider');
check('types: raider = the base craft under a named id', raider.frame === 0 && raider.hull === base.hull && raider.weapon.range === base.weapon.range);
const bomber = enemyTypeConfig('bomber');
check('types: bomber overrides merge onto the base',
bomber.frame === 1 && bomber.speed === 110 && bomber.hull === 90 && bomber.shields === 20 &&
bomber.weapon.range === 720 && bomber.weapon.cooldown === 1.8 && bomber.weapon.damage === 12);
check('types: bomber inherits what it does not set',
bomber.weapon.spread === base.weapon.spread && bomber.weapon.bulletLife === base.weapon.bulletLife &&
bomber.ramDamage === base.ramDamage && bomber.hitRadius === base.hitRadius && bomber.texture === base.texture);
const unknown = enemyTypeConfig('nope');
check('types: unknown id → the base craft (warned, not thrown)', unknown === base);
const sheets = enemySheetList();
check('sheets: base first, one entry per source file (bomber shares the sheet)',
sheets.length === 1 && sheets[0].src === base.texture && sheets[0].frameWidth === 128 && sheets[0].frameHeight === 128);
const scene = makeScene({ withSheet: true });
const b = new EnemyShip(scene, 0, 0, 0, bomber);
check('enemy type: a bomber flies with its own stats (frame 1, hull 90, same scale)',
b.frame === 1 && b.maxHull === 90 && b.maxShields === 20 && approx(b.scale, E('size', 54) / 128));
}
// ------------------------------------------------------------------- waves
{
check('waves: the shipped scripts (probe/skirmish/raid)',
['probe', 'skirmish', 'raid'].every((n) => Array.isArray(config.get(`combat.waves.scripts.${n}`))));
check('waves: the script defaults ship',
config.get('combat.waves.defaults.maxConcurrent', 0) >= 1 && config.get('combat.waves.defaults.replacePerKill', 0) >= 0);
const bad = [normalizeWaves([]), normalizeWaves({ waves: [] }), normalizeWaves('nope'), normalizeWaves([{ kind: 'rolling' }])];
check('waves: empty / unknown / shapeless scripts → rejected with an error',
bad.every((b) => !b.ok && typeof b.error === 'string'));
const inline = normalizeWaves([{ kind: 'rolling', roster: { raider: 2, bomber: 1 } }]);
check('waves: the roster weight sum = the default force', inline.ok && inline.waves[0].total === 3 && inline.waves[0].kind === 'rolling');
check('waves: the script lead/gap ship (seconds → ms)',
inline.lead === Number(config.get('combat.waves.lead', 1.5)) * 1000 && inline.waves[0].gapMs === Number(config.get('combat.waves.gap', 2.0)) * 1000);
const shorthand = normalizeWaves([{ kind: 'fixed', type: 'bomber', count: 4 }]);
check('waves: type/count shorthand → a single-type roster', shorthand.ok && shorthand.waves[0].total === 4 && Object.keys(shorthand.waves[0].roster).join() === 'bomber');
const perGap = normalizeWaves([
{ kind: 'fixed', roster: { raider: 1 }, total: 2 },
{ kind: 'fixed', roster: { raider: 1 }, total: 2, gap: 0.5 },
]);
check('waves: a per-wave gap (s) overrides the script gap', perGap.ok && perGap.waves[1].gapMs === 500 && perGap.waves[0].gapMs === perGap.gap);
const named = normalizeWaves('raid');
check('waves: the named "raid" script (fixed 6 → rolling 12 at cap 4)',
named.ok && named.waves.length === 2 &&
named.waves[0].kind === 'fixed' && named.waves[0].total === 6 &&
named.waves[1].kind === 'rolling' && named.waves[1].total === 12 && named.waves[1].maxConcurrent === 4);
}
{
// ROLLING: the cap holds; every kill sends its replacement in; the force is spent.
const roll = new WaveRunner(normalizeWaves([{ kind: 'rolling', roster: { raider: 1 }, total: 6, maxConcurrent: 2, replacePerKill: 1 }]));
let spawned = 0, killed = 0, peak = 0;
spawned += roll.begin().length;
peak = Math.max(peak, roll.current.alive);
check('runner: rolling begins at the cap (min(maxConcurrent, force))', roll.current.alive === 2);
while (!roll.waveCleared()) {
killed += 1;
spawned += roll.onKill().length;
peak = Math.max(peak, roll.current.alive);
if (killed > 20) throw new Error('rolling wave never cleared');
}
check('runner: rolling spends exactly its force (6 kills, 6 deployed)', killed === 6 && spawned === 6);
check('runner: the cap is never exceeded (2 on screen)', peak <= 2);
// FIXED: the whole force at once, no replacements until exhausted.
const fixed = new WaveRunner(normalizeWaves([{ kind: 'fixed', roster: { raider: 3, bomber: 1 }, total: 4 }]));
check('runner: fixed deploys the whole force at once', fixed.begin().length === 4);
let fKills = 0;
while (!fixed.waveCleared()) { fixed.onKill(); fKills += 1; if (fKills > 10) break; }
check('runner: fixed sends no replacements until exhausted', fKills === 4);
// MULTI-WAVE: next() hands over, then the run is done.
const two = new WaveRunner(normalizeWaves([
{ kind: 'fixed', roster: { raider: 1 }, total: 1 },
{ kind: 'fixed', roster: { raider: 1 }, total: 1 },
]));
two.begin();
two.onKill();
check('runner: wave 1 cleared, wave 2 follows', two.waveCleared() === true && two.hasNext === true);
check('runner: next() deploys wave 2, then the run is done', two.next().length === 1 && two.next() === null && two.done === true);
const st = two.status();
check('runner: status reports the spent run (wave 2 of 2)', st.wave === 1 && st.waves === 2 && st.state === 'done' && st.total === 1);
}
{
// The roster's weighted draws — deterministic given the rng.
const wave = { total: 4, roster: { raider: 1, bomber: 3 } };
check('roster: a draw at 0 takes the first entry', resolveRoster(wave, () => 0).every((t) => t === 'raider'));
check('roster: a draw near 1 takes the last entry', resolveRoster(wave, () => 0.999999).every((t) => t === 'bomber'));
const mixedSeq = [0.2, 0.5, 0.2, 0.5];
let mi = 0;
const mixed = resolveRoster(wave, () => mixedSeq[mi++ % mixedSeq.length]);
check('roster: draws split by weight (low → raider, mid → bomber)',
mixed.length === 4 && mixed.filter((t) => t === 'bomber').length === 2 && mixed.filter((t) => t === 'raider').length === 2);
}
{
// The RUN, driven through CombatSystem (the quest hook's exact path):
// lead → wave → gap → wave → done, kills feeding the rolling cap.
const scene = makeScene({ withSheet: true });
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const events = { start: [], clear: [], complete: null, cancel: 0 };
const combat = new CombatSystem(scene, { ship, onKill: () => {} });
combat.autoFire.enabled = false; // the test asserts the RUN, not the guns
const script = {
waves: [
{ kind: 'rolling', roster: { raider: 1 }, total: 3, maxConcurrent: 2, replacePerKill: 1 },
{ kind: 'fixed', roster: { bomber: 1 }, total: 2 },
],
lead: 0,
gap: 0,
};
const run = combat.startWaves(script, {
onWaveStart: (i) => events.start.push(i),
onWaveClear: (i) => events.clear.push(i),
onComplete: (s) => (events.complete = s),
onCancel: () => events.cancel++,
});
check('waves/system: a run is live (the bar comes up in the lead)',
run !== null && combat.active === true && combat.waveStatus().state === 'lead');
let t = 1000;
combat.update(t, 16);
const wave1 = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: wave 1 deploys at the cap (rolling 2 of 3)', wave1.length === 2 && events.start.length === 1);
check('waves/system: wave ships are run-owned (no respawn clock)', wave1.every((e) => e._respawnPending !== true));
t += 16;
combat.update(t, 16);
check('waves/system: the live wave holds its cap (no unforced spawns)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 2);
const killOne = () => {
const e = combat.enemies.find((x) => x.wave && !x.dead);
e.damage(999); // the real path is a tracer sweep → damage() → killEnemy
combat.killEnemy(e, t);
};
killOne();
check('waves/system: a rolling kill sends its replacement in (cap holds)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 2);
killOne();
killOne();
check('waves/system: the rolling force is spent (3 kills)',
combat.enemies.filter((e) => e.wave && !e.dead).length === 0 && combat.kills === 3);
combat.update(t + 16, 16);
check('waves/system: wave 1 clears (the clear event fires)', events.clear.length === 1);
combat.update(t + 32, 16);
const bombers = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: wave 2 deploys whole (fixed: the force at once)', bombers.length === 2);
check('waves/system: the roster type flies (bomber stats, not the base)',
bombers.every((e) => e.maxHull === 90 && e.frame === 1));
for (const e of [...bombers]) { e.damage(999); combat.killEnemy(e, t + 48); }
combat.update(t + 64, 16);
check('waves/system: the run completes (5 spent = 3 + 2, onComplete fires)',
combat.waveStatus().state === 'done' && combat.kills === 5 &&
events.complete !== null && events.start.length === 2 && events.clear.length === 2);
check('waves/system: combat over → the bar drops (no live ships, no run)', combat.active === false);
// The NAMED script (the shape the quests reference) + cancel.
const run2 = combat.startWaves('probe', { onCancel: () => events.cancel++ });
check('waves/system: the named "probe" script starts (lead in flight)',
run2 !== null && combat.waveStatus().state === 'lead' && combat.active === true);
combat.update(t + 1000, 16);
check('waves/system: nothing deploys during the lead',
combat.enemies.filter((e) => e.wave && !e.dead).length === 0);
combat.update(t + 3200, 16);
const probe = combat.enemies.filter((e) => e.wave && !e.dead);
check('waves/system: the lead elapses → "probe" deploys (fixed, 3 base raiders)',
probe.length === 3 && probe.every((e) => e.maxHull === 60));
combat.cancelWaves(true);
check('waves/system: cancel fires the event + pulls the run ships out',
events.cancel === 1 && combat.enemies.every((e) => !e.wave));
combat.destroy();
check('waves/system: destroy clears the run', combat.waveRun === null);
}
// ------------------------------------------------------------------ done // ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

View File

@ -8,6 +8,7 @@ import { fontStack, themeColor } from '../utils/Theme.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { EnemyShip } from './EnemyShip.js'; import { EnemyShip } from './EnemyShip.js';
import { enemySheetList } from './EnemyTypes.js';
import { Bullet } from './Bullet.js'; import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js'; import { Fx, FX_COLORS } from './Fx.js';
import { StarDrift } from './StarDrift.js'; import { StarDrift } from './StarDrift.js';
@ -66,13 +67,12 @@ export class CombatScene extends Phaser.Scene {
}); });
} }
// The enemy sheet (data/combat.json → enemy.texture). // The enemy sheets (data/combat.json → enemy.texture + the enemyTypes'
const enemyTex = cfg.enemy?.texture ?? ''; // own textures — EnemyTypes.enemySheetList is the seam, one key per
if (enemyTex && !this.textures.exists(EnemyShip.TEXTURE_KEY)) { // source file, EnemyShip.textureKey).
this.load.spritesheet(EnemyShip.TEXTURE_KEY, enemyTex, { for (const { src, frameWidth, frameHeight } of enemySheetList()) {
frameWidth: cfg.enemy?.frameWidth ?? 128, if (this.textures.exists(EnemyShip.textureKey(src))) continue;
frameHeight: cfg.enemy?.frameHeight ?? 128, this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
});
} }
// The combat voices (js/combat/CombatSfx.js — the shared event→sound // The combat voices (js/combat/CombatSfx.js — the shared event→sound
@ -368,8 +368,9 @@ export class CombatScene extends Phaser.Scene {
const player = this.playerDead ? null : this.ship; const player = this.playerDead ? null : this.ship;
for (const e of this.enemies) { for (const e of this.enemies) {
if (e.dead) { if (e.dead) {
// Respawn keeps the range stocked while tuning (enemy.respawn). // Respawn keeps the range stocked while tuning (the craft's own
if (config.get('combat.enemy.respawn', true) && time >= e.respawnAt) this.reviveEnemy(e); // type config — the base enemy here; wave ships never respawn).
if (e.respawn !== false && time >= e.respawnAt) this.reviveEnemy(e);
continue; continue;
} }
const wantsFire = e.update(time, delta, player); const wantsFire = e.update(time, delta, player);
@ -406,7 +407,7 @@ export class CombatScene extends Phaser.Scene {
e.body.velocity.x = 0; e.body.velocity.x = 0;
e.body.velocity.y = 0; e.body.velocity.y = 0;
} }
e.respawnAt = this.time.now + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000; e.respawnAt = this.time.now + (e.respawnDelayMs ?? 0);
} }
reviveEnemy(e) { reviveEnemy(e) {
@ -566,13 +567,13 @@ export class CombatScene extends Phaser.Scene {
const t = this.time.now; const t = this.time.now;
if (t >= this.ramUntil) { if (t >= this.ramUntil) {
this.ramUntil = t + (Number(config.get('combat.enemy.ramGrace', 0.8)) || 0.8) * 1000; this.ramUntil = t + (b.ramGraceMs ?? 800);
const midX = (a.x + b.x) / 2; const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2; const midY = (a.y + b.y) / 2;
this.fx.flash(midX, midY, 0xffffff, 46, 160); this.fx.flash(midX, midY, 0xffffff, 46, 160);
const preShields = b.shields; const preShields = b.shields;
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; const ramSelf = b.ramDamageSelf ?? 0;
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull this.damagePlayer(b.ramDamage ?? 0); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull'); this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this, preShields, ramSelf); combatSfxEnemyHit(this, preShields, ramSelf);
const killed = b.damage(ramSelf); const killed = b.damage(ramSelf);

View File

@ -1,9 +1,11 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js'; import { EnemyShip } from './EnemyShip.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js'; import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS } from './Fx.js'; import { Fx, FX_COLORS } from './Fx.js';
import { applyDamage, pickTarget } from './Combat.js'; import { applyDamage, pickTarget } from './Combat.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js'; import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { normalizeWaves, WaveRunner } from './Waves.js';
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
@ -23,6 +25,13 @@ const TAU = Math.PI * 2;
* the cannon tracks; the cannon's numbers are → player.weapon) * the cannon tracks; the cannon's numbers are → player.weapon)
* - the collision pass (swept bullet hits + rams — the main game's * - the collision pass (swept bullet hits + rams — the main game's
* hand-rolled circle-check style, no arcade colliders) * 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 * The scene supplies the player ship (a Ship, or anything with
* .x/.y/.radius/.body/.stop()/.setVisible()) and calls * .x/.y/.radius/.body/.stop()/.setVisible()) and calls
@ -60,6 +69,10 @@ export class CombatSystem {
this.bullets = []; this.bullets = [];
this.kills = 0; 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 player's life (playerShip.* — both contexts share it).
const p = config.section('combat.playerShip', {}); const p = config.section('combat.playerShip', {});
this.maxHull = Math.max(1, Number(p.hull) || 100); this.maxHull = Math.max(1, Number(p.hull) || 100);
@ -103,8 +116,14 @@ export class CombatSystem {
// ------------------------------------------------------------- the pack // ------------------------------------------------------------- the pack
/** Combat is live when a hostile is up, or one is on its reform clock. */ /**
* 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() { get active() {
const run = this.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return true;
return this.enemies.some( return this.enemies.some(
(e) => !e.dead || (e._respawnPending && e.respawnAt > 0), (e) => !e.dead || (e._respawnPending && e.respawnAt > 0),
); );
@ -118,24 +137,48 @@ export class CombatSystem {
if (this.onActiveChange) this.onActiveChange(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 * Spawn the starting pack in a ring around (cx, cy) — the ?combat boot
* calls this with the ship's position "when the player starts". * 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 * @returns {EnemyShip[]} the fresh hostiles
*/ */
spawnPack(cx, cy) { spawnPack(cx, cy) {
const out = []; const out = [];
for (let i = 0; i < this.spawnCount; i++) { for (let i = 0; i < this.spawnCount; i++) {
const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6; const a = (i / Math.max(1, this.spawnCount)) * TAU + (Math.random() - 0.5) * 0.6;
const r = this.spawnRadius * (0.9 + Math.random() * 0.2); 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)); out.push(this.spawnEnemy(cx + Math.cos(a) * r, cy + Math.sin(a) * r, i));
} }
return out; return out;
} }
/** One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its bearing. */ /**
spawnEnemy(x, y, index) { * One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its
const e = new EnemyShip(this.scene, x, y, index); * 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); e.setDepth(10);
const px = this.ship ? this.ship.x : x; const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y; const py = this.ship ? this.ship.y : y;
@ -146,6 +189,153 @@ export class CombatSystem {
return e; 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().
*
* @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, off-screen around the ship (_offscreenRadius's band —
* the same off-screen materialization as the ?combat pack), bearing in
* on its own chase AI.
* @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;
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 // ------------------------------------------------------------ the loop
/** /**
@ -213,6 +403,9 @@ export class CombatSystem {
} }
this.fx.update(delta); 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). */ /** The nearest live hostile to (x, y), or null (the shared pickTarget rule). */
@ -391,7 +584,8 @@ export class CombatSystem {
/** /**
* A ship-ship collision: push apart, exchange normal velocity (bounce), * A ship-ship collision: push apart, exchange normal velocity (bounce),
* and deal ram damage to BOTH (grace-limited — enemy.ram*). * 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) { ramPair(a, b) {
const dx = b.x - a.x; const dx = b.x - a.x;
@ -425,13 +619,13 @@ export class CombatSystem {
const t = this._now(); const t = this._now();
if (t >= this.ramUntil) { if (t >= this.ramUntil) {
this.ramUntil = t + (Number(config.get('combat.enemy.ramGrace', 0.8)) || 0.8) * 1000; this.ramUntil = t + (b.ramGraceMs ?? 800);
const midX = (a.x + b.x) / 2; const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2; const midY = (a.y + b.y) / 2;
this.fx.flash(midX, midY, '#ffffff', 46, 160); this.fx.flash(midX, midY, '#ffffff', 46, 160);
const preShields = b.shields; const preShields = b.shields;
const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; const ramSelf = b.ramDamageSelf ?? 0;
this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull this.damagePlayer(b.ramDamage ?? 0); // impact + shield/hull
this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull'); this.fx.damage(b.x, b.y, ramSelf, preShields > 0 ? 'shield' : 'hull');
combatSfxEnemyHit(this.scene, preShields, ramSelf); combatSfxEnemyHit(this.scene, preShields, ramSelf);
const killed = b.damage(ramSelf); const killed = b.damage(ramSelf);
@ -492,9 +686,11 @@ export class CombatSystem {
} }
/** /**
* A hostile went down: the kill FX + bookkeeping, and (when armed) the * A hostile went down: the kill FX + bookkeeping. WAVE SHIPS get spent
* reform clock. Idempotent via _killFx — damage() flips e.dead first, * (their kill feeds the runner's rolling replacements — no respawn
* so the dead flag can't be the "already counted" guard. * 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) { killEnemy(e, time) {
if (e._killFx) return; if (e._killFx) return;
@ -507,9 +703,15 @@ export class CombatSystem {
e.body.velocity.x = 0; e.body.velocity.x = 0;
e.body.velocity.y = 0; e.body.velocity.y = 0;
} }
const respawn = config.get('combat.enemy.respawn', true) !== false; const run = this.waveRun;
const inWave = e.wave === true && run && run.state === 'live';
const respawn = !inWave && e.respawn !== false;
e._respawnPending = respawn; e._respawnPending = respawn;
e.respawnAt = time + (Number(config.get('combat.enemy.respawnDelay', 2.5)) || 0) * 1000; 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); if (this.onKill) this.onKill(e);
this._setActive(); this._setActive();
} }
@ -560,6 +762,7 @@ export class CombatSystem {
/** Tear the combat down (scene destroy). */ /** Tear the combat down (scene destroy). */
destroy() { destroy() {
this.waveRun = null;
for (const e of this.enemies) e.destroy(); for (const e of this.enemies) e.destroy();
for (const b of this.bullets) if (!b.dead) b.destroy(); for (const b of this.bullets) if (!b.dead) b.destroy();
this.enemies = []; this.enemies = [];

View File

@ -1,6 +1,6 @@
import Phaser from '../vendor/phaser.js'; import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js'; import { toColor } from '../utils/Color.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { applyDamage, headingOf, orbitStep } from './Combat.js'; import { applyDamage, headingOf, orbitStep } from './Combat.js';
const FALLBACK_W = 76; // the procedural wedge (it faces east) const FALLBACK_W = 76; // the procedural wedge (it faces east)
@ -17,15 +17,30 @@ const FALLBACK_H = 46;
* `frame` picks which one (frame 0 = the red triple-boost fighter, art * `frame` picks which one (frame 0 = the red triple-boost fighter, art
* facing north, filling its frame). The art's facing works exactly like * facing north, filling its frame). The art's facing works exactly like
* the player's (js/entities/Ship.js): a constant render offset * the player's (js/entities/Ship.js): a constant render offset
* (this.artOffset) so heading math stays world-up = -π/2. If the sheet * (this.artOffset) so heading math stays world-up = -π/2. A type may ride
* isn't configured or didn't load, the built-in wedge is drawn instead * the same sheet on a different `frame`, or bring its own `texture`
* (it faces east, offset 0). * (EnemyTypes.enemySheetList is the preload seam; one texture key per
* source file). If the sheet isn't configured or didn't load, the
* built-in wedge is drawn instead (it faces east, offset 0).
* *
* Armor: { shields, hull } per js/combat/Combat.js (shields absorb * Armor: { shields, hull } per js/combat/Combat.js (shields absorb
* first). damage() applies a hit; the scene owns the FX + respawn. * first). damage() applies a hit; the scene owns the FX + respawn.
*
* Config: the constructor takes the craft's FULL config (an
* EnemyTypes.enemyTypeConfig merge — the base `combat.enemy` craft, or a
* named type's overrides merged onto it). Null = the base type. The
* per-type numbers (armor, chase feel, weapon, ram, respawn) all read
* off THAT object — the wave runner's ships and the sandbox's pack share
* one code path, only the config differs.
*/ */
export class EnemyShip extends Phaser.Physics.Arcade.Sprite { export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
static TEXTURE_KEY = '__enemy_ship'; /** The texture key a source file lands under (the no-texture wedge's key is src-less). */
static FALLBACK_KEY = '__enemy_ship_fallback';
static textureKey(src) {
if (typeof src !== 'string' || src === '') return EnemyShip.FALLBACK_KEY;
const name = src.split('/').pop().replace(/\.[^.]+$/, ''); // 'ships-enemy'
return `__enemy_ship_${name}`;
}
/** World angle (rad) for each direction the sheet art may face. */ /** World angle (rad) for each direction the sheet art may face. */
static FACING_ANGLE = { static FACING_ANGLE = {
@ -36,12 +51,14 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
}; };
/** /**
* Ensure a texture exists under TEXTURE_KEY. The spritesheet (loaded * Ensure a texture exists under the key for `src`. The spritesheet
* by CombatScene.preload / GameScene.preload) wins; otherwise the * (loaded by CombatScene.preload / GameScene.preload from
* built-in wedge — a blunt V-fin raider, clearly NOT the player dart. * EnemyTypes.enemySheetList) wins; otherwise the built-in wedge — a
* blunt V-fin raider, clearly NOT the player dart.
*/ */
static ensureTexture(scene) { static ensureTexture(scene, src) {
if (scene.textures.exists(EnemyShip.TEXTURE_KEY)) return; const key = EnemyShip.textureKey(src);
if (scene.textures.exists(key)) return;
const w = FALLBACK_W; const w = FALLBACK_W;
const h = FALLBACK_H; const h = FALLBACK_H;
@ -58,7 +75,7 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
g.fillPath(); g.fillPath();
g.fillStyle(trim, 1); g.fillStyle(trim, 1);
g.fillCircle(Math.round(w - 20), h / 2, 4); // cockpit g.fillCircle(Math.round(w - 20), h / 2, 4); // cockpit
g.generateTexture(EnemyShip.TEXTURE_KEY, w, h); g.generateTexture(key, w, h);
g.destroy(); g.destroy();
} }
@ -67,11 +84,16 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
* @param {number} x spawn * @param {number} x spawn
* @param {number} y spawn * @param {number} y spawn
* @param {number} index the craft's slot in the pack (orbit direction alternates) * @param {number} index the craft's slot in the pack (orbit direction alternates)
* @param {object} [cfg] the craft's FULL enemy config (EnemyTypes.enemyTypeConfig's
* merge — the base `combat.enemy` craft or a named type's). Null = the base.
*/ */
constructor(scene, x, y, index) { constructor(scene, x, y, index, cfg = null) {
const hasSheet = scene.textures.exists(EnemyShip.TEXTURE_KEY); const c = cfg && typeof cfg === 'object' ? cfg : enemyTypeConfig();
EnemyShip.ensureTexture(scene); const src = typeof c.texture === 'string' ? c.texture : '';
super(scene, x, y, EnemyShip.TEXTURE_KEY, config.get('combat.enemy.frame', 0)); const key = EnemyShip.textureKey(src);
const hasSheet = src !== '' && scene.textures.exists(key);
if (!hasSheet) EnemyShip.ensureTexture(scene, src);
super(scene, x, y, key, Number(c.frame) || 0);
scene.add.existing(this); scene.add.existing(this);
scene.physics.add.existing(this); scene.physics.add.existing(this);
@ -82,38 +104,44 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
// World size + render scale (the same contract as Ship: size × the // World size + render scale (the same contract as Ship: size × the
// art's footprint = the hull's world length). // art's footprint = the hull's world length).
this.size = Math.max(4, config.get('combat.enemy.size', 54)); this.size = Math.max(4, Number(c.size) || 54);
const frameWidth = hasSheet const frameWidth = hasSheet ? Math.max(1, Number(c.frameWidth) || 128) : FALLBACK_W;
? Math.max(1, config.get('combat.enemy.frameWidth', 128))
: FALLBACK_W;
this.setScale(this.size / frameWidth); this.setScale(this.size / frameWidth);
const facing = EnemyShip.FACING_ANGLE[config.get('combat.enemy.artFacing', 'east')] ?? 0; const facing = EnemyShip.FACING_ANGLE[c.artFacing ?? 'east'] ?? 0;
this.artOffset = hasSheet ? -facing : 0; this.artOffset = hasSheet ? -facing : 0;
// A circle body (world px — data/combat.json → enemy.hitRadius): // A circle body (world px — the type's hitRadius): the scene's
// the scene's collision pass uses it for rams + bullets. // collision pass uses it for rams + bullets.
this.hitRadius = Math.max(4, config.get('combat.enemy.hitRadius', 20)); this.hitRadius = Math.max(4, Number(c.hitRadius) || 20);
const body = this.body; const body = this.body;
body.setCircle(this.hitRadius / this.scale); // texture-space radius (auto-scales) body.setCircle(this.hitRadius / this.scale); // texture-space radius (auto-scales)
body.setAllowGravity(false); body.setAllowGravity(false);
// Armor (js/combat/Combat.js rule: shields first, then hull). // Armor (js/combat/Combat.js rule: shields first, then hull).
this.maxHull = Math.max(1, config.get('combat.enemy.hull', 60)); this.maxHull = Math.max(1, Number(c.hull) || 60);
this.maxShields = Math.max(0, config.get('combat.enemy.shields', 0)); this.maxShields = Math.max(0, Number(c.shields) || 0);
this.hull = this.maxHull; this.hull = this.maxHull;
this.shields = this.maxShields; this.shields = this.maxShields;
this.dead = false; this.dead = false;
// Chase feel (data/combat.json → enemy). // Chase feel (the type's numbers).
this.speed = Math.max(0, config.get('combat.enemy.speed', 165)); this.speed = Math.max(0, Number(c.speed) ?? 165);
this.turn = Math.max(0.1, config.get('combat.enemy.turn', 3.2)); this.turn = Math.max(0.1, Number(c.turn) ?? 3.2);
this.orbitRadius = Math.max(40, config.get('combat.enemy.orbitRadius', 300)); this.orbitRadius = Math.max(40, Number(c.orbitRadius) ?? 300);
this.orbitSpeed = config.get('combat.enemy.orbitSpeed', 0.6); this.orbitSpeed = Number(c.orbitSpeed) ?? 0.6;
// Lance (data/combat.json → enemy.weapon). // Lance (the type's weapon.*).
this.wpn = config.section('combat.enemy.weapon', {}); this.wpn = c.weapon && typeof c.weapon === 'object' ? c.weapon : {};
this.nextFireAt = 0; // ms — the scene grants a short grace on spawn this.nextFireAt = 0; // ms — the scene grants a short grace on spawn
// Ram + respawn (per-type — CombatSystem reads them off the ship;
// the wave runner's ships never take the respawn clock).
this.ramDamage = Number(c.ramDamage) ?? 10;
this.ramDamageSelf = Number(c.ramDamageSelf) ?? 22;
this.ramGraceMs = (Number(c.ramGrace) ?? 0.8) * 1000;
this.respawn = c.respawn !== false;
this.respawnDelayMs = (Number(c.respawnDelay) ?? 2.5) * 1000;
// The last player position (orbiting it while the player is down). // The last player position (orbiting it while the player is down).
this.lastPx = x; this.lastPx = x;
this.lastPy = y; this.lastPy = y;

99
js/combat/EnemyTypes.js Normal file
View File

@ -0,0 +1,99 @@
/**
* The ENEMY TYPE registry — the pure half of wave-composable hostiles
* (js/combat/Waves.js builds the waves, js/combat/CombatSystem.js spawns
* them). No Phaser, no scene: it reads the config and returns data, so it
* runs in bare Node (dev/combat.test.mjs exercises exactly this).
*
* The base craft is data/combat.json → enemy (the sandbox's hostile — the
* red triple-boost fighter). `enemyTypes` names VARIANTS for wave rosters:
* each entry is a PARTIAL config deep-merged onto the base — set only what
* differs (hull/shields, the chase feel, weapon.*, ram*, or
* texture/frame for a different sheet — the base's numbers are the
* fallback for every field a type doesn't set).
*
* combat.enemy = the base type (always available, unnamed)
* combat.enemyTypes = { "<id>": { overrides } }
*
* A wave's roster (Waves.js) names these ids; an unknown id falls back to
* the base craft (with a console warning — a roster typo should be
* visible, not fatal).
*/
import { config } from '../config/Config.js';
const isPlain = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
/**
* Deep-merge two plain objects: `over` wins, nested plain objects merge,
* arrays/primitives replace. Neither input is mutated (they may be the
* config's live sections — the merge result is a fresh object).
*
* @param {object} base the base config (e.g. combat.enemy)
* @param {object} over the overrides (e.g. combat.enemyTypes[id])
* @returns {object} the merged config
*/
export function deepMerge(base, over) {
const out = { ...(base ?? {}) };
if (isPlain(over)) {
for (const [k, v] of Object.entries(over)) {
if (isPlain(v) && isPlain(out[k])) out[k] = deepMerge(out[k], v);
else if (v !== undefined) out[k] = v;
}
}
return out;
}
/** The named type ids (combat.enemyTypes, minus the `_` bookkeeping keys). */
export function enemyTypeIds() {
const t = config.section('combat.enemyTypes', {});
return Object.keys(t).filter((k) => !k.startsWith('_') && isPlain(t[k]));
}
/**
* The FULL config for a hostile type: the base (combat.enemy) deep-merged
* with the type's overrides (combat.enemyTypes[id]).
*
* @param {string|null} [typeId] null/'' = the base craft; a named id =
* base + that type's overrides; an unknown id = the base (warned once
* per call — a roster typo, not a crash).
* @returns {object} the config an EnemyShip builds from
*/
export function enemyTypeConfig(typeId = null) {
const base = config.section('combat.enemy', {});
if (typeId == null || typeId === '') return base;
const t = config.section('combat.enemyTypes', {});
const over = t[typeId];
if (!isPlain(over)) {
if (typeof console !== 'undefined') {
console.warn(`[orbit] enemyTypes: unknown type "${typeId}" — the base craft flies instead`);
}
return base;
}
return deepMerge(base, over);
}
/**
* Every DISTINCT spritesheet the hostiles use (the base's texture + each
* named type's, deduped, base first) — the preload seam: a scene loads
* exactly this list (js/scenes/GameScene.js, js/combat/CombatScene.js).
* Types without their own texture ride the base sheet (a different
* `frame` picks their art).
*
* @returns {Array<{src: string, frameWidth: number, frameHeight: number}>}
*/
export function enemySheetList() {
const out = [];
const seen = new Set();
const add = (c) => {
const src = isPlain(c) ? c.texture : null;
if (typeof src !== 'string' || src === '' || seen.has(src)) return;
seen.add(src);
out.push({
src,
frameWidth: Math.max(1, Number(c.frameWidth) || 128),
frameHeight: Math.max(1, Number(c.frameHeight) || 128),
});
};
add(config.section('combat.enemy', {}));
for (const id of enemyTypeIds()) add(enemyTypeConfig(id));
return out;
}

303
js/combat/Waves.js Normal file
View File

@ -0,0 +1,303 @@
/**
* WAVE SCRIPTS — the pure state machine behind quest-driven combat (and
* later, random encounters). A script is an ordered list of waves; this
* module knows nothing of Phaser or scenes — it hands back "spawn these
* type ids NOW" and js/combat/CombatSystem.js places them in the world
* (a ring around the ship). Runs in bare Node (dev/combat.test.mjs).
*
* Wave kinds (data/combat.json → waves):
* 'rolling' — maxConcurrent (X) on screen at a time; every kill sends in
* replacePerKill (Y) more until the wave's force is spent.
* 'fixed' — the whole force at once, no replacements until exhausted.
*
* A wave's composition: `roster` (type → weight, ids from
* combat.enemyTypes) and `total` (the units deployed; default = the
* roster's weight sum — so {raider: 2, bomber: 1} + total 6 is a 2:1 mix
* of six hostiles). `type` + `count` is the single-type shorthand.
*
* The runner's contract (CombatSystem drives it from update()):
* begin() → type ids to spawn (wave 1, once the lead elapses)
* onKill() → type ids to spawn (the rolling replacements)
* waveCleared() → the current wave's force is spent
* next() → type ids to spawn (the next wave — null when done)
*
* The runner owns no clock: the caller passes "now" and decides when the
* lead (norm.lead ms) / the gap (a wave's gapMs) has elapsed.
*/
import { config } from '../config/Config.js';
const KINDS = new Set(['rolling', 'fixed']);
const isPlain = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
const num = (v, fallback) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
/**
* The wave section's defaults (data/combat.json → waves). Seconds → ms.
* @returns {{maxConcurrent: number, replacePerKill: number, lead: number, gap: number}}
*/
export function waveDefaults() {
const w = config.section('combat.waves', {});
const d = w.defaults ?? {};
return {
maxConcurrent: Math.max(1, num(d.maxConcurrent, 3)),
replacePerKill: Math.max(0, num(d.replacePerKill, 1)),
lead: Math.max(0, num(w.lead, 1.5)) * 1000,
gap: Math.max(0, num(w.gap, 2.0)) * 1000,
};
}
/**
* One wave's fields (defaults from the wave section; a per-wave `gap`
* in SECONDS overrides the script gap).
* @returns {object|null} the normalized wave — null when the shape is bad
*/
function normalizeWave(w, defs, gapSec) {
if (!isPlain(w)) return null;
const kind = w.kind ?? 'rolling';
if (!KINDS.has(kind)) return null;
// The composition: the roster's weights (+ the type/count shorthand).
const roster = {};
if (isPlain(w.roster)) {
for (const [t, n] of Object.entries(w.roster)) {
if (typeof t !== 'string' || t === '') continue;
const weight = Number(n);
if (Number.isFinite(weight) && weight > 0) roster[t] = (roster[t] ?? 0) + weight;
}
}
if (w.type != null) {
const t = String(w.type);
roster[t] = (roster[t] ?? 0) + Math.max(0, num(w.count, 1));
}
if (Object.keys(roster).length === 0) return null; // nothing to send
// The force: the units deployed (default = the weight sum).
const weightSum = Object.values(roster).reduce((a, b) => a + b, 0);
const total = Math.floor(Math.max(1, num(w.total ?? w.totalForce, weightSum)));
if (total < 1) return null;
return {
kind,
roster,
total,
maxConcurrent: Math.max(1, num(w.maxConcurrent, defs.maxConcurrent)),
replacePerKill: Math.max(0, num(w.replacePerKill, defs.replacePerKill)),
gapMs: Math.max(0, num(w.gap, gapSec)) * 1000,
};
}
/**
* Normalize a wave script for the runner.
*
* `script` is an ordered list of waves, or `{ waves: [...], lead?, gap? }`
* (lead/gap in seconds), or a NAME under combat.waves.scripts (the shape
* the quests reference when their hook lands).
*
* @param {Array|object|string} script the script (above)
* @param {object} [defs] waveDefaults() — injectable for tests
* @returns {{ok: true, waves: object[], lead: number, gap: number} | {ok: false, error: string}}
* lead/gap in ms; each wave carries its own gapMs (the delay BEFORE it).
*/
export function normalizeWaves(script, defs = waveDefaults()) {
let waves;
let leadSec;
let gapSec;
if (typeof script === 'string') {
const named = config.section('combat.waves.scripts', {})[script];
if (!Array.isArray(named)) {
return { ok: false, error: `no wave script named "${script}" (combat.waves.scripts)` };
}
waves = named;
} else if (isPlain(script)) {
waves = script.waves;
leadSec = script.lead;
gapSec = script.gap;
} else {
waves = script;
}
if (!Array.isArray(waves) || waves.length === 0) {
return { ok: false, error: 'a wave script is a non-empty ordered list of waves' };
}
gapSec = num(gapSec, defs.gap / 1000);
const out = [];
for (let i = 0; i < waves.length; i++) {
const w = normalizeWave(waves[i], defs, gapSec);
if (!w) {
return {
ok: false,
error: `wave ${i + 1}: needs a kind (rolling/fixed) and a roster ({type: weight}) or the type/count shorthand`,
};
}
out.push(w);
}
return {
ok: true,
waves: out,
lead: Math.max(0, num(leadSec, defs.lead / 1000)) * 1000,
gap: Math.max(0, gapSec) * 1000,
};
}
/**
* Draw a wave's force from its roster: `total` weighted picks (with
* replacement) over the type weights — the spawn order the runner deploys.
* Deterministic given the same `rng` (tests seed one; the game uses
* Math.random).
*
* @param {{total: number, roster: object}} wave a normalized wave
* @param {() => number} [rng] random source in [0, 1)
* @returns {string[]} the type ids (length = wave.total)
*/
export function resolveRoster(wave, rng = Math.random) {
const entries = Object.entries(wave.roster ?? {}).filter(
([t, n]) => typeof t === 'string' && t !== '' && num(n, 0) > 0,
);
const sum = entries.reduce((a, [, n]) => a + num(n, 0), 0);
const total = Math.max(0, Math.floor(num(wave.total, 0)));
if (entries.length === 0 || total === 0) return [];
const draw = typeof rng === 'function' ? rng : Math.random;
const picks = [];
for (let i = 0; i < total; i++) {
let r = draw() * sum;
let pick = entries[0][0];
for (const [t, n] of entries) {
r -= num(n, 0);
if (r <= 0) {
pick = t;
break;
}
}
picks.push(pick);
}
return picks;
}
/**
* One run of a normalized script: the wave-by-wave state machine.
* Clocks belong to the caller (CombatSystem passes the scene time); this
* only answers "who spawns now" and "is the force spent".
*/
export class WaveRunner {
/**
* @param {object} norm the ok result of normalizeWaves()
* @param {object} [o]
* resolve (wave, rng) → type ids (default resolveRoster)
* rng random source for the composition draws (tests seed it)
*/
constructor(norm, { resolve = resolveRoster, rng = Math.random } = {}) {
this.waves = (norm?.waves ?? []).map((w) => ({
...w,
queue: resolve(w, rng),
alive: 0,
deployed: 0,
}));
this.index = -1;
this.state = 'lead'; // 'lead' → 'live' → ('gap' → 'live')… → 'done'
}
/** How many waves the script holds. */
get totalWaves() {
return this.waves.length;
}
/** The current wave (its live state), or null before begin(). */
get current() {
return this.index >= 0 ? this.waves[this.index] : null;
}
/** True once the last wave's force is spent. */
get done() {
return this.state === 'done';
}
/** True while another wave follows the current one. */
get hasNext() {
return this.index + 1 < this.waves.length;
}
/**
* Start wave 1 (from 'lead').
* @returns {string[]} the type ids to spawn NOW (min(maxConcurrent, total)
* for a rolling wave, the whole force for a fixed one)
*/
begin() {
if (this.state !== 'lead' || this.waves.length === 0) return [];
this.index = 0;
this.state = 'live';
return this._deploy(this.waves[0]);
}
/**
* Start the next wave (after the caller let the gap elapse).
* @returns {string[]|null} the type ids to spawn — null when the script is spent
*/
next() {
if (this.state === 'done') return null;
if (!this.hasNext) {
this.state = 'done';
return null;
}
this.index += 1;
this.state = 'live';
return this._deploy(this.waves[this.index]);
}
/**
* A wave ship was killed (while 'live'). A rolling wave sends in its
* replacements here (capped by the seats and the remaining force); a
* fixed one sends nothing.
* @returns {string[]} the type ids to spawn NOW
*/
onKill() {
if (this.state !== 'live') return [];
const w = this.current;
w.alive = Math.max(0, w.alive - 1);
if (w.kind !== 'rolling') return [];
const room = w.maxConcurrent - w.alive;
const n = Math.max(0, Math.min(w.replacePerKill, room, w.queue.length));
if (n === 0) return [];
w.deployed += n;
w.alive += n;
return w.queue.splice(0, n);
}
/** The current wave's force is spent (every unit deployed, every one down). */
waveCleared() {
const w = this.current;
return this.state === 'live' && !!w && w.alive === 0 && w.queue.length === 0;
}
/**
* A status snapshot (the console's waves.status() / the quest hook's UI).
* @returns {{state: string, wave: number, waves: number, kind: string|null, alive: number, deployed: number, inQueue: number, total: number, killed: number}}
*/
status() {
const w = this.current;
return {
state: this.state,
wave: Math.max(0, this.index),
waves: this.totalWaves,
kind: w?.kind ?? null,
alive: w?.alive ?? 0,
deployed: w?.deployed ?? 0,
inQueue: w?.queue.length ?? 0,
total: w?.total ?? 0,
killed: w ? w.total - w.alive - w.queue.length : 0,
};
}
/** Deploy the wave's opening burst (seats for rolling, all for fixed). */
_deploy(w) {
const n = w.kind === 'fixed' ? w.queue.length : Math.min(w.maxConcurrent, w.queue.length);
if (n === 0) return [];
const out = w.queue.splice(0, n);
w.deployed += n;
w.alive += n;
return out;
}
}

View File

@ -13,6 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js'; import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js'; import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js'; import { Station } from '../entities/Station.js';
@ -153,20 +154,14 @@ export class GameScene extends Phaser.Scene {
); );
} }
// The ENEMY ship spritesheet (data/combat.json → enemy.texture): // The ENEMY spritesheets (data/combat.json → enemy.texture + the
// the hostiles' art for the combat sandbox (js/combat/, combat.html). // enemyTypes' own textures — EnemyTypes.enemySheetList is the seam,
// Loaded here too — the texture is available in the main game for // one key per source file, EnemyShip.textureKey). Loaded here too —
// when combat ships in; the sandbox is where the numbers get tuned. // the art is available in the main game for the WAVE RUNS
const enemyTexture = config.get('combat.enemy.texture', ''); // (js/combat/Waves.js → CombatSystem.startWaves); the sandbox is
if (enemyTexture) { // where the numbers get tuned.
this.load.spritesheet( for (const { src, frameWidth, frameHeight } of enemySheetList()) {
EnemyShip.TEXTURE_KEY, this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
enemyTexture,
{
frameWidth: config.get('combat.enemy.frameWidth', 128),
frameHeight: config.get('combat.enemy.frameHeight', 128),
},
);
} }
// The asteroid spritesheet (data/asteroids.json → texture): 128×128 // The asteroid spritesheet (data/asteroids.json → texture): 128×128
@ -974,17 +969,33 @@ export class GameScene extends Phaser.Scene {
kills: () => this.combat?.kills ?? 0, kills: () => this.combat?.kills ?? 0,
spawn: (n = 1) => { spawn: (n = 1) => {
if (!this.combat || !this.ship) return; if (!this.combat || !this.ship) return;
const r = this.combat._offscreenRadius();
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
this.combat.spawnEnemy( this.combat.spawnEnemy(
this.ship.x + Math.cos(a) * 420, this.ship.x + Math.cos(a) * r,
this.ship.y + Math.sin(a) * 420, this.ship.y + Math.sin(a) * r,
100 + i, 100 + i,
); );
} }
}, },
damagePlayer: (n = 10) => this.combat?.damagePlayer(n), damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
cast: (i) => this.combat?.cast(i), cast: (i) => this.combat?.cast(i),
// WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a
// script (a combat.waves.scripts NAME or a raw wave list), watch
// it, cancel it. The QUEST HOOK that fires startWaves lands
// later; until then this console seam IS the entry point.
waves: {
start: (script, opts) => this.combat?.startWaves(script, opts) ?? null,
cancel: (destroy = false) => this.combat?.cancelWaves(destroy) ?? null,
status: () => this.combat?.waveStatus() ?? null,
demo: (name = 'raid') =>
this.combat?.startWaves(name, {
onWaveStart: (i) => console.info(`[orbit] wave ${i + 1} in`),
onWaveClear: (i) => console.info(`[orbit] wave ${i + 1} cleared`),
onComplete: (s) => console.info('[orbit] combat complete', s),
}) ?? null,
},
}; };
} }
console.info( console.info(