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,
|
||||
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 { 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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue