diff --git a/data/combat.json b/data/combat.json index 8f01e7d..051e557 100644 --- a/data/combat.json +++ b/data/combat.json @@ -3,9 +3,10 @@ "enabled": true, "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, "spawnRadius": 340, + "spawnLead": 140, "autoFire": { "enabled": true, "range": 800 @@ -73,6 +74,49 @@ "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": { "_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, diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 67ec829..953e56a 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -170,6 +170,12 @@ const { applyDamage, headingOf, orbitStep, handleFeel, pickTarget, feedPush, off pathToFileURL(join(__dirname, '../js/combat/Combat.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 { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.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); } +// ------------------------------------------------- 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 { @@ -633,6 +687,202 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); 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 console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); diff --git a/js/combat/CombatScene.js b/js/combat/CombatScene.js index b713393..376001c 100644 --- a/js/combat/CombatScene.js +++ b/js/combat/CombatScene.js @@ -8,6 +8,7 @@ import { fontStack, themeColor } from '../utils/Theme.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; import { Ship } from '../entities/Ship.js'; import { EnemyShip } from './EnemyShip.js'; +import { enemySheetList } from './EnemyTypes.js'; import { Bullet } from './Bullet.js'; import { Fx, FX_COLORS } from './Fx.js'; import { StarDrift } from './StarDrift.js'; @@ -66,13 +67,12 @@ export class CombatScene extends Phaser.Scene { }); } - // The enemy sheet (data/combat.json → enemy.texture). - const enemyTex = cfg.enemy?.texture ?? ''; - if (enemyTex && !this.textures.exists(EnemyShip.TEXTURE_KEY)) { - this.load.spritesheet(EnemyShip.TEXTURE_KEY, enemyTex, { - frameWidth: cfg.enemy?.frameWidth ?? 128, - frameHeight: cfg.enemy?.frameHeight ?? 128, - }); + // The enemy sheets (data/combat.json → enemy.texture + the enemyTypes' + // own textures — EnemyTypes.enemySheetList is the seam, one key per + // source file, EnemyShip.textureKey). + for (const { src, frameWidth, frameHeight } of enemySheetList()) { + if (this.textures.exists(EnemyShip.textureKey(src))) continue; + this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight }); } // 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; for (const e of this.enemies) { if (e.dead) { - // Respawn keeps the range stocked while tuning (enemy.respawn). - if (config.get('combat.enemy.respawn', true) && time >= e.respawnAt) this.reviveEnemy(e); + // Respawn keeps the range stocked while tuning (the craft's own + // type config — the base enemy here; wave ships never respawn). + if (e.respawn !== false && time >= e.respawnAt) this.reviveEnemy(e); continue; } 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.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) { @@ -566,13 +567,13 @@ export class CombatScene extends Phaser.Scene { const t = this.time.now; 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 midY = (a.y + b.y) / 2; this.fx.flash(midX, midY, 0xffffff, 46, 160); const preShields = b.shields; - const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; - this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull + 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, preShields, ramSelf); const killed = b.damage(ramSelf); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 6273755..6957f76 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -1,9 +1,11 @@ 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; @@ -23,6 +25,13 @@ const TAU = Math.PI * 2; * 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 @@ -60,6 +69,10 @@ export class CombatSystem { 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); @@ -103,8 +116,14 @@ export class CombatSystem { // ------------------------------------------------------------- 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() { + 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), ); @@ -118,24 +137,48 @@ export class CombatSystem { 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". + * 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.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)); } return out; } - /** One hostile (a dev/probe spawn, or a wave). Anchors its orbit to its bearing. */ - spawnEnemy(x, y, index) { - const e = new EnemyShip(this.scene, x, y, index); + /** + * 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; @@ -146,6 +189,153 @@ export class CombatSystem { 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 /** @@ -213,6 +403,9 @@ export class CombatSystem { } 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). */ @@ -391,7 +584,8 @@ export class CombatSystem { /** * 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) { const dx = b.x - a.x; @@ -425,13 +619,13 @@ export class CombatSystem { const t = this._now(); 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 midY = (a.y + b.y) / 2; this.fx.flash(midX, midY, '#ffffff', 46, 160); const preShields = b.shields; - const ramSelf = Number(config.get('combat.enemy.ramDamageSelf', 22)) || 0; - this.damagePlayer(Number(config.get('combat.enemy.ramDamage', 10)) || 0); // impact + shield/hull + 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); @@ -492,9 +686,11 @@ export class CombatSystem { } /** - * A hostile went down: the kill FX + bookkeeping, and (when armed) the - * reform clock. Idempotent via _killFx — damage() flips e.dead first, - * so the dead flag can't be the "already counted" guard. + * 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; @@ -507,9 +703,15 @@ export class CombatSystem { e.body.velocity.x = 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.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); this._setActive(); } @@ -560,6 +762,7 @@ export class CombatSystem { /** 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 = []; diff --git a/js/combat/EnemyShip.js b/js/combat/EnemyShip.js index da1ed5c..f13e91a 100644 --- a/js/combat/EnemyShip.js +++ b/js/combat/EnemyShip.js @@ -1,6 +1,6 @@ import Phaser from '../vendor/phaser.js'; -import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; +import { enemyTypeConfig } from './EnemyTypes.js'; import { applyDamage, headingOf, orbitStep } from './Combat.js'; 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 * facing north, filling its frame). The art's facing works exactly like * the player's (js/entities/Ship.js): a constant render offset - * (this.artOffset) so heading math stays world-up = -π/2. If the sheet - * isn't configured or didn't load, the built-in wedge is drawn instead - * (it faces east, offset 0). + * (this.artOffset) so heading math stays world-up = -π/2. A type may ride + * the same sheet on a different `frame`, or bring its own `texture` + * (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 * 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 { - 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. */ 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 - * by CombatScene.preload / GameScene.preload) wins; otherwise the - * built-in wedge — a blunt V-fin raider, clearly NOT the player dart. + * Ensure a texture exists under the key for `src`. The spritesheet + * (loaded by CombatScene.preload / GameScene.preload from + * EnemyTypes.enemySheetList) wins; otherwise the built-in wedge — a + * blunt V-fin raider, clearly NOT the player dart. */ - static ensureTexture(scene) { - if (scene.textures.exists(EnemyShip.TEXTURE_KEY)) return; + static ensureTexture(scene, src) { + const key = EnemyShip.textureKey(src); + if (scene.textures.exists(key)) return; const w = FALLBACK_W; const h = FALLBACK_H; @@ -58,7 +75,7 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite { g.fillPath(); g.fillStyle(trim, 1); g.fillCircle(Math.round(w - 20), h / 2, 4); // cockpit - g.generateTexture(EnemyShip.TEXTURE_KEY, w, h); + g.generateTexture(key, w, h); g.destroy(); } @@ -67,11 +84,16 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite { * @param {number} x spawn * @param {number} y spawn * @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) { - const hasSheet = scene.textures.exists(EnemyShip.TEXTURE_KEY); - EnemyShip.ensureTexture(scene); - super(scene, x, y, EnemyShip.TEXTURE_KEY, config.get('combat.enemy.frame', 0)); + constructor(scene, x, y, index, cfg = null) { + const c = cfg && typeof cfg === 'object' ? cfg : enemyTypeConfig(); + const src = typeof c.texture === 'string' ? c.texture : ''; + 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.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 // art's footprint = the hull's world length). - this.size = Math.max(4, config.get('combat.enemy.size', 54)); - const frameWidth = hasSheet - ? Math.max(1, config.get('combat.enemy.frameWidth', 128)) - : FALLBACK_W; + this.size = Math.max(4, Number(c.size) || 54); + const frameWidth = hasSheet ? Math.max(1, Number(c.frameWidth) || 128) : FALLBACK_W; 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; - // A circle body (world px — data/combat.json → enemy.hitRadius): - // the scene's collision pass uses it for rams + bullets. - this.hitRadius = Math.max(4, config.get('combat.enemy.hitRadius', 20)); + // A circle body (world px — the type's hitRadius): the scene's + // collision pass uses it for rams + bullets. + this.hitRadius = Math.max(4, Number(c.hitRadius) || 20); const body = this.body; body.setCircle(this.hitRadius / this.scale); // texture-space radius (auto-scales) body.setAllowGravity(false); // Armor (js/combat/Combat.js rule: shields first, then hull). - this.maxHull = Math.max(1, config.get('combat.enemy.hull', 60)); - this.maxShields = Math.max(0, config.get('combat.enemy.shields', 0)); + this.maxHull = Math.max(1, Number(c.hull) || 60); + this.maxShields = Math.max(0, Number(c.shields) || 0); this.hull = this.maxHull; this.shields = this.maxShields; this.dead = false; - // Chase feel (data/combat.json → enemy). - this.speed = Math.max(0, config.get('combat.enemy.speed', 165)); - this.turn = Math.max(0.1, config.get('combat.enemy.turn', 3.2)); - this.orbitRadius = Math.max(40, config.get('combat.enemy.orbitRadius', 300)); - this.orbitSpeed = config.get('combat.enemy.orbitSpeed', 0.6); + // Chase feel (the type's numbers). + this.speed = Math.max(0, Number(c.speed) ?? 165); + this.turn = Math.max(0.1, Number(c.turn) ?? 3.2); + this.orbitRadius = Math.max(40, Number(c.orbitRadius) ?? 300); + this.orbitSpeed = Number(c.orbitSpeed) ?? 0.6; - // Lance (data/combat.json → enemy.weapon). - this.wpn = config.section('combat.enemy.weapon', {}); + // Lance (the type's weapon.*). + this.wpn = c.weapon && typeof c.weapon === 'object' ? c.weapon : {}; 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). this.lastPx = x; this.lastPy = y; diff --git a/js/combat/EnemyTypes.js b/js/combat/EnemyTypes.js new file mode 100644 index 0000000..a936808 --- /dev/null +++ b/js/combat/EnemyTypes.js @@ -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 = { "": { 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; +} diff --git a/js/combat/Waves.js b/js/combat/Waves.js new file mode 100644 index 0000000..3d86862 --- /dev/null +++ b/js/combat/Waves.js @@ -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; + } +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index e8337a8..82ef5b4 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -13,6 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { Ship } from '../entities/Ship.js'; import { EnemyShip } from '../combat/EnemyShip.js'; +import { enemySheetList } from '../combat/EnemyTypes.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.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 hostiles' art for the combat sandbox (js/combat/, combat.html). - // Loaded here too — the texture is available in the main game for - // when combat ships in; the sandbox is where the numbers get tuned. - const enemyTexture = config.get('combat.enemy.texture', ''); - if (enemyTexture) { - this.load.spritesheet( - EnemyShip.TEXTURE_KEY, - enemyTexture, - { - frameWidth: config.get('combat.enemy.frameWidth', 128), - frameHeight: config.get('combat.enemy.frameHeight', 128), - }, - ); + // The ENEMY spritesheets (data/combat.json → enemy.texture + the + // enemyTypes' own textures — EnemyTypes.enemySheetList is the seam, + // one key per source file, EnemyShip.textureKey). Loaded here too — + // the art is available in the main game for the WAVE RUNS + // (js/combat/Waves.js → CombatSystem.startWaves); the sandbox is + // where the numbers get tuned. + for (const { src, frameWidth, frameHeight } of enemySheetList()) { + this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight }); } // The asteroid spritesheet (data/asteroids.json → texture): 128×128 @@ -974,17 +969,33 @@ export class GameScene extends Phaser.Scene { kills: () => this.combat?.kills ?? 0, spawn: (n = 1) => { if (!this.combat || !this.ship) return; + const r = this.combat._offscreenRadius(); for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; this.combat.spawnEnemy( - this.ship.x + Math.cos(a) * 420, - this.ship.y + Math.sin(a) * 420, + this.ship.x + Math.cos(a) * r, + this.ship.y + Math.sin(a) * r, 100 + i, ); } }, damagePlayer: (n = 10) => this.combat?.damagePlayer(n), 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(