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:
parent
b2cd5a2701
commit
11f315dbf0
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue