Add combat XP leveling: kills earn XP, level-ups bump armor

- Kills grant the hostile type's own `xp` (the roster sets XP-per-threat;
  the curve lives in data/progression.json, 100 x n^2, capped at level 80)
- Each crossed level fires one `levelup` event: +3 hull / +2 shields with
  a full heal, cyan burst + a distinct voice, and a kill-feed line — the
  placeholder toughness the skill layer will subscribe to later
- Combat bar gains an XP row below the orbs (level, live gold fill,
  banked / next cost; hidden when the snapshot carries no xp, so the
  range bar keeps its old face)
- Kills show a gold "+N" reward floater; the level-up's SFX is the new
  8th combat event (solar_flare_01)
- XpManager is pure and tested headless (node dev/xp.test.mjs); the
  ledger saves as { level, xp } and old saves load fresh at level 0
- dev hooks (xp/grantXp), sfxcheck (range seven, main eight), shot
  captures, and README all follow along
This commit is contained in:
Brian Fertig 2026-09-18 09:57:07 -06:00
parent a690ca74a2
commit b461bc98af
18 changed files with 1040 additions and 37 deletions

View File

@ -48,9 +48,14 @@ respawns, the bar — lives in **`data/combat.json`**:
the left, the ENERGY gauge on the right, eight skill seats wearing
keys **1–8** in the middle (the skills land later — for now they're
empty seats; a press lands on the system's `cast()` stub). Wipe the
pack and the deck comes back. Poke the live fight in DevTools via
pack and the deck comes back. Kills also earn **combat XP** (the
run's level ledger — `js/combat/XpManager.js`; the curve is
`data/progression.json`, each type's value is its own `xp` in
`data/combat.json`) — a level-up bumps the ship's armor (the
placeholder toughness), fires a burst + a distinct voice, and lands
a line in the kill feed. Poke the live fight in DevTools via
`window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn,
damagePlayer, cast }`.
damagePlayer, cast, xp, grantXp }`.
- **Feel** — the handling is what the range tunes. Every feel number
(thrust, top speed, drag, turn rate + turn response, throttle ramp/
@ -68,13 +73,14 @@ respawns, the bar — lives in **`data/combat.json`**:
**player goes down**, and a REFORMED bloom on **respawn**. Shield
reads cyan and hull reads orange in both the flash and the number, so
a glance tells you which layer took the hit.
- **Feedback — SFX** — the same seven events each get a *distinct* voice
- **Feedback — SFX** — the same eight events each get a *distinct* voice
from the existing `data/sfx.json` set (no new sound files), through
one shared event→voice map + playback path (`js/combat/CombatSfx.js`)
so the two fronts can't drift: firing = `scan`, impact = `ui_click`,
shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04`
(one at random), down = `deconstruct`, respawn = `discovery`. Enemy
fire reuses the `scan` voice, quieter.
(one at random), down = `deconstruct`, respawn = `discovery`,
level-up = `solar_flare_01`. Enemy fire reuses the `scan` voice,
quieter.
- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure
rules in `js/combat/Combat.js`) makes the fight *readable* in both
fronts: a rotating **lock reticle** on the current target (the same
@ -83,12 +89,29 @@ respawns, the bar — lives in **`data/combat.json`**:
hostiles outside the camera (the main game's camera follows the ship),
a **kill feed** (newest-first, top-right), and a **gauge flash** — the
combat bar's shields/hull gauge lights the moment it takes the hit.
- **Combat levels (XP)** — kills grant the hostile's type's own `xp`
(the roster sets XP-per-threat: the base craft 10, the bomber 25 —
`data/combat.json → enemy.xp`), banked against a curve that's easy at
first and hard later, never a grind (`data/progression.json` —
`base × n^power`, shipped 100 × n², capped at level 80; ten base kills
buy level 1, the whole run to the cap is 17.4M XP). A crossed level
fires a `levelup` EVENT (one per level — the armor bump
(`progression.reward`: +3 hull / +2 shields, full heal), the burst +
voice, the feed's line all ride it; the skill layer — active and
passive, the post-cap progression — will subscribe to that same event
later). At the cap the level holds and the overflow is harmlessly
banked. The ledger is part of the save as `{ level, xp }` (old saves
load — a fresh run starts at level 0), and the bar's XP row (below
the orbs — the gold summary strip) shows the level, the run toward
the next, and the numbers (banked over the next cost; at the cap the
banked total). `node dev/xp.test.mjs` covers
the curve, the grants, the cap, the events, and the save round-trip.
- **Dev hooks + checkers** — besides the two pokes above, every SFX call
is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the
wiring is verifiable headless in both fronts. CDP checkers drive the
real game and read state back: `node dev/combat-flycheck.mjs`
(flight feel — t60/t30, spin, stop distance, top speed),
`node dev/combat.sfxcheck.mjs` (all seven SFX events fire through the
`node dev/combat.sfxcheck.mjs` (all eight SFX events fire through the
shared path, in both the range and the main game), and
`node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame
per context through the production paths into `dev/shots/` — the
@ -254,6 +277,7 @@ orbit/
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── combat.json # COMBAT: the range (player feel, enemy craft + AI + lance, rams, respawns, arena) + the main-game demo (game.*, energy, the bar)
│ ├── progression.json # COMBAT LEVELS: the XP curve (base × n^power, the cap) + the per-level armor reward
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
@ -276,7 +300,7 @@ orbit/
│ ├── main.js # entry point: load config → boot Phaser (?combat boots the normal game with the combat demo live)
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── combat/ # COMBAT: CombatSystem (the main-game pack — auto-fire, tracers, rams, armor, energy), the range (CombatScene, js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
│ ├── combat/ # COMBAT: CombatSystem (the main-game pack — auto-fire, tracers, rams, armor, energy), the XP ledger (XpManager — levels from kills, data/progression.json), the range (CombatScene, js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)

View File

@ -45,7 +45,7 @@
},
"enemy": {
"_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
"_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). xp = the EXP a kill of one is worth (the XP bank's currency — js/combat/XpManager.js; the curve is data/progression.json, and the base value is 10, so ten kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
"texture": "assets/images/ships-enemy.png",
"frameWidth": 128,
"frameHeight": 128,
@ -60,6 +60,7 @@
"orbitSpeed": 0.6,
"hull": 60,
"shields": 0,
"xp": 10,
"weapon": {
"range": 560,
"cooldown": 1.4,
@ -81,7 +82,7 @@
},
"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.",
"_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. Each type's `xp` = what a kill of it is worth (default: the base enemy.xp — the rule of thumb is (hull+shields)/5, rounded, so a tougher craft is worth more of the XP bank).",
"raider": {
"frame": 0
},
@ -91,6 +92,7 @@
"turn": 2.2,
"hull": 90,
"shields": 20,
"xp": 25,
"weapon": {
"range": 720,
"cooldown": 1.8,

View File

@ -37,6 +37,7 @@
"goods.json",
"economy.json",
"shopui.json",
"combat.json"
"combat.json",
"progression.json"
]
}

11
data/progression.json Normal file
View File

@ -0,0 +1,11 @@
{
"_comment": "COMBAT PROGRESSION — the combat level + experience bank (js/combat/XpManager.js, wired into js/combat/CombatSystem.js's kill bookkeeping). maxLevel = the level CAP (the player starts at level 0 — the barometer: level 1 costs 10 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 100 XP (10 × the raider's 10 XP, data/combat.json → enemy.xp), and each level's cost is n² — the relative step SHRINKS (4×, 2.25×, 1.78× … 1.03×), so early levels fly by and the late ones never become a wall (the 'easier than WoW' pitch: with an honest roster, kills-per-level grow LINEARLY, 10n). power = the GRIND INTENSITY knob (lower = gentler back half — 1.8; higher = steeper — 2.2); base scales the WHOLE curve (lower base = faster early levels). Enemy XP is PER-TYPE (each combat.enemyTypes entry's own `xp` — the rule of thumb is (hull+shields)/5, rounded): the curve is fixed in XP, the roster sets XP-per-threat, so pacing tunes two independent dials. CAP BEHAVIOR: the level holds at maxLevel and the XP is BANKED (xp keeps counting — harmless; the post-cap currency will be the skill layer, not XP). reward = the per-level ARMOR BUMP the level-up grants (hullPerLevel/shieldPerLevel added to combat.playerShip's max armor; fullHealOnLevelUp refills both) — the placeholder toughness until the skill layer lands (a passive can later override or stack these).",
"maxLevel": 80,
"base": 100,
"power": 2,
"reward": {
"hullPerLevel": 3,
"shieldPerLevel": 2,
"fullHealOnLevelUp": true
}
}

View File

@ -19,7 +19,9 @@
* flash + damage number, kill burst + feed,
* player-hit flash + gauge flash
* combat-main.png — index.html?combat: the same set in the
* main game, with the real combat bar gauging
* main game, with the real combat bar gauging —
* plus a level-up beat (the LEVEL floater over the
* ship + the bar's XP row at level 1, live fill)
*
* Every event goes through the shared production code (CombatSfx / Fx /
* CombatHud), so the frame is a faithful picture of what a player sees.
@ -204,6 +206,10 @@ async function main() {
if (e) { e.shields = 0; e.damage(9999); sys.killEnemy(e, sys._now()); }
sys.shields = 40; sys.hull = 100;
sys.damagePlayer(8);
// The level-up beat (the kill's XP + this grant crosses level 1
// — the LEVEL floater off the ship, the armor bump, and the bar's
// XP row at level 1 with a live fill) — captured, ~110 ms in.
sys.xp.grant(300, 'shot');
return true;
})()`);
await p.shot(`${OUT}/combat-main.png`);

View File

@ -8,7 +8,9 @@
* (http://127.0.0.1:8080/index.html?combat) — and asserts that each of
* the seven combat events (fire / impact / shield / hull / kill /
* down / respawn) went through the SHARED SFX path
* (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log).
* (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log) — and, in
* the main game, the EIGHTH event, levelup (the combat level landing —
* the run's XP bank, fed through the dev hook).
*
* Why a log: a headless browser has no audio manager, so the shared
* guards (js/utils/Sfx.js) stay silent — the dev log records the call
@ -20,7 +22,8 @@
*/
const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333';
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080';
const EVENTS = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn'];
const EVENTS_RANGE = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn'];
const EVENTS_MAIN = [...EVENTS_RANGE, 'levelup']; // the main game's XP bank
function connect(target) {
const ws = new WebSocket(target.webSocketDebuggerUrl);
@ -67,11 +70,12 @@ async function openPage(path) {
* Drive the seven events in `scope` (a JS expression resolving to a
* scope object) and return the distinct events observed in the dev log.
*/
function driveScript(scopeExpr) {
function driveScript(scopeExpr, withLevelUp = false) {
return `(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const scope = ${scopeExpr};
if (!scope) return 'no scope';
const driveLevelUp = ${Boolean(withLevelUp)}; // injected at build time
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
// fire — the cannon goes off (player side, full voice).
@ -88,6 +92,11 @@ function driveScript(scopeExpr) {
// kill — a hostile goes down.
scope.killOne();
// levelup — a combat level lands (the main game's XP bank — the
// level's beat fires the solar_flare voice; the range has no XP
// bank, so it drives the seven).
if (driveLevelUp) scope.levelUp();
// down — the player's hull breaks.
scope.killPlayer();
@ -101,7 +110,7 @@ function driveScript(scopeExpr) {
})()`;
}
async function checkContext(name, path, scopeExpr, startExpr) {
async function checkContext(name, path, scopeExpr, startExpr, expected, withLevelUp = false) {
const { c, target } = await openPage(path);
try {
// Give the game time to boot (assets, fonts).
@ -130,11 +139,11 @@ async function checkContext(name, path, scopeExpr, startExpr) {
if (!scoped) throw new Error('combat scope never appeared');
await new Promise((r) => setTimeout(r, 800));
const events = await evalJs(c, driveScript(scopeExpr));
const events = await evalJs(c, driveScript(scopeExpr, withLevelUp));
if (!Array.isArray(events)) throw new Error(`drive failed: ${events}`);
const missing = EVENTS.filter((e) => !events.includes(e));
const missing = expected.filter((e) => !events.includes(e));
if (missing.length) throw new Error(`missing events: ${missing.join(', ')}`);
console.log(`${name}: all seven events fired through the shared SFX path`);
console.log(`${name}: all ${expected.length} events fired through the shared SFX path`);
return true;
} catch (err) {
console.error(`${name}: FAIL — ${err.message}`);
@ -188,11 +197,17 @@ const scopeMain = `(() => {
sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999);
},
respawn: () => sys.revivePlayer(sys._now()),
levelUp: () => {
// A grant big enough to cross at least one level (a fresh run
// pays 100 for level 1 — take the WHOLE cost + a kill's worth,
// so a restore mid-level can't starve it).
sys.xp.grant((sys.xp.xpForNext() ?? 100) + 100, 'sfxcheck');
},
};
})()`;
const results = [];
results.push(await checkContext('range (/combat)', '/combat', scopeRange));
results.push(await checkContext('range (/combat)', '/combat', scopeRange, null, EVENTS_RANGE));
results.push(await checkContext(
'main (index.html?combat)',
'/index.html?combat',
@ -206,10 +221,12 @@ results.push(await checkContext(
}
return 'menu never ready';
})()`,
EVENTS_MAIN,
true,
));
if (results.every(Boolean)) {
console.log('combat SFX wiring: PASS (both contexts, all seven events)');
console.log('combat SFX wiring: PASS (both contexts — the range\'s seven, the main game\'s eight)');
process.exit(0);
} else {
console.log('combat SFX wiring: FAIL');

View File

@ -610,9 +610,9 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
};
const sfxNames = config.get('sfx') ?? {};
check('sfx: the seven combat events, each mapped',
check('sfx: the eight combat events, each mapped',
Object.keys(COMBAT_SFX).sort().join(',') ===
'down,fire,hull,impact,kill,respawn,shield');
'down,fire,hull,impact,kill,levelup,respawn,shield');
check('sfx: every mapped voice exists in data/sfx.json',
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 54 KiB

365
dev/xp.test.mjs Normal file
View File

@ -0,0 +1,365 @@
/**
* Combat XP test (dev tool, run with Node — no browser needed):
*
* node dev/xp.test.mjs
*
* Exercises the combat level + experience bank (js/combat/XpManager.js —
* the pure rules: the curve from data/progression.json, the grants, the
* multi-level bursts, the cap's banked overflow, the EVENT seam the skill
* layer will subscribe to, the save round-trip) — then the system's
* wiring (js/combat/CombatSystem.js: a kill grants the hostile's type's
* own `xp` (data/combat.json → enemy.xp / enemyTypes.bomber.xp), a
* crossed level's beat fires the armor bump (data/progression.json →
* reward) + the onLevelUp callback, and a RESTORED ledger's level applies
* the bump on build with no events).
*
* All numbers come from the real data/*.json (data/progression.json +
* data/combat.json included), so the assertions track the shipped config,
* not a private copy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fs = await import('node:fs');
// ------------------------------------------------------------------ stubs
// The minimum of Phaser + a scene for the REAL combat modules (the same
// seams dev/combat.test.mjs exercises — the manager itself needs none).
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { XpManager } = await import(
pathToFileURL(join(__dirname, '../js/combat/XpManager.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// ------------------------------------------------------------------- curve
{
const m = new XpManager();
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
// The curve (data/progression.json — base × n^power, shipped: 100 × n²):
check('curve: level 1 = 100 XP (the data, not a private copy)', m.xpForLevel(1) === 100);
check('curve: level 1 = ten base kills (the barometer)', m.xpForLevel(1) === 10 * baseXp);
check('curve: level 2 = 400 XP', m.xpForLevel(2) === 400);
check('curve: level 3 = 900 XP', m.xpForLevel(3) === 900);
check('curve: level 80 = 640,000 XP', m.xpForLevel(80) === 640000);
check('curve: quadratic (n²) — level n = n² × level 1', m.xpForLevel(5) === 25 * m.xpForLevel(1));
check('curve: the relative step shrinks (2→3 is a smaller × than 1→2)',
m.xpForLevel(3) / m.xpForLevel(2) < m.xpForLevel(2) / m.xpForLevel(1));
check('curve: the cap is the level (80), not the formula', m.maxLevel === 80);
check('curve: xpForLevel(<1) = 0', m.xpForLevel(0) === 0 && m.xpForLevel(-4) === 0);
check('curve: fresh ledger (level 0, 0 xp, not max)',
m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 100);
}
// ------------------------------------------------------------- the grants
{
const m = new XpManager();
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
// The barometer, as a player would see it: ten kills of the base craft.
let levelUps = 0;
const sub = m.on('levelup', () => levelUps++);
for (let i = 0; i < 10; i++) m.grant(baseXp, 'kill');
check('grant: ten base kills → level 1 (xp 0, the bank is spent)',
m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 400);
sub();
m.grant(999);
check('grant: the unsubscribe stops the listener (no more levelup events)',
levelUps === 1);
// A BIG grant crosses several levels — one 'levelup' event EACH (an
// "unlocks exactly at level N" rule can't miss its level). 10,000 buys
// 100+400+900+1600+2500+3600 = 9,100 → level 6, 900 banked toward 7.
const m2 = new XpManager();
const crossed = [];
m2.on('levelup', (ev) => crossed.push(ev.to));
const r = m2.grant(10000, 'dev'); // → level 6
check("grant: a big grant crosses to level 6 (the curve's math)",
r.level === 6 && r.levelsGained === 6 && r.leveledUp === true && m2.xp === 900);
check('grant: one levelup event PER level crossed (1,2,3,4,5,6)',
JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6]));
// The source tag rides the events (the ledger's audit trail).
const m3 = new XpManager();
const srcs = [];
m3.on('grant', (ev) => srcs.push(ev.source));
m3.grant(10, 'kill');
m3.grant(50, 'quest');
check('grant: the source tag rides each event',
JSON.stringify(srcs) === JSON.stringify(['kill', 'quest']));
// grant(0) / junk: safe no-ops, no events.
const m4 = new XpManager();
let grants = 0;
m4.on('grant', () => grants++);
const r0 = m4.grant(0);
const rJunk = m4.grant('junk');
check('grant: 0 / junk amounts are no-ops (no events, no state)',
grants === 0 && r0.gained === 0 && rJunk.gained === 0 && m4.xp === 0);
// A throwing listener can't wedge the bank.
const m5 = new XpManager();
m5.on('levelup', () => { throw new Error('a bad subscriber'); });
let threw = false;
try {
m5.grant(100, 'kill');
} catch {
threw = true;
}
check('events: a throwing listener is caught (the grant still lands)',
threw === false && m5.level === 1);
}
// ---------------------------------------------------------------- the cap
{
const m = new XpManager();
const max = m.maxLevel;
const maxEvents = [];
const levelEvents = [];
m.on('max', (ev) => maxEvents.push(ev));
m.on('levelup', (ev) => levelEvents.push(ev.to));
// A grant bigger than the WHOLE curve (the sum of levels 1..80 =
// 17,388,000): the level caps, the overflow is BANKED (harmless — the
// post-cap currency will be skills, not XP).
const totalToCap = (100 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 100
const huge = totalToCap + 12345;
const r = m.grant(huge, 'dev');
check('cap: the level holds at the cap (80)', r.level === max && m.isMax === true);
check('cap: the overflow is banked (xp keeps counting)', m.xp === 12345);
check('cap: the "max" event fires ONCE, with the cap level',
maxEvents.length === 1 && maxEvents[0].level === max);
check('cap: all 80 levels crossed, one event each',
levelEvents.length === max && levelEvents[max - 1] === max);
// More grants at the cap: the bank grows, nothing else does.
const xpBefore = m.xp;
m.grant(500, 'kill');
check('cap: grants at the cap just bank (no events, the level holds)',
m.xp === xpBefore + 500 && m.level === max && maxEvents.length === 1);
// The banked bank survives the save round-trip (level + xp, verbatim).
const json = m.toJSON();
check('cap: the save is tiny ({level, xp})',
json.level === max && json.xp === xpBefore + 500);
const m2 = XpManager.fromJSON(json);
let events = 0;
m2.on('levelup', () => events++);
m2.on('grant', () => events++);
check('cap: the restore is silent (a load is not a level-up)',
events === 0 && m2.level === max && m2.xp === json.xp && m2.isMax === true);
check('cap: at the cap, xpForNext is null + progress is full',
m2.xpForNext() === null && m2.stats().progress === 1);
}
// --------------------------------------------------- events / save / reset
{
const m = new XpManager();
m.grant(550, 'kill'); // 550 = 100 + 400 + 50 → level 2, 50 banked
check('state: level 2 with 50 banked toward level 3', m.level === 2 && m.xp === 50);
const json = m.toJSON();
check('save: the record is {level, xp} — the curve stays in data',
json.level === 2 && json.xp === 50 && Object.keys(json).length === 2);
const m2 = XpManager.fromJSON(json);
check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 50);
// A restore clamps junk (a hand-edited record can't break the run).
const m3 = XpManager.fromJSON({ level: 999, xp: -50 });
check('restore: level clamps to the cap, xp floors at 0',
m3.level === m3.maxLevel && m3.xp === 0);
const m4 = XpManager.fromJSON(null);
check('restore: a null state is the fresh ledger', m4.level === 0 && m4.xp === 0);
// reset: the new run's ledger (a menu action, not a save load).
m2.reset();
check('reset: back to the fresh ledger (level 0, 0 xp)', m2.level === 0 && m2.xp === 0);
// stats(): the bar's snapshot (progress 0..1, the next level's cost).
const m5 = new XpManager();
m5.grant(50, 'kill');
const s = m5.stats();
check('stats: level 0, 50/100 toward level 1',
s.level === 0 && s.xp === 50 && s.xpForNext === 100 && approx(s.progress, 0.5) && s.isMax === false);
m5.grant(60, 'kill');
const s2 = m5.stats();
check('stats: after crossing, 10/400 toward level 2',
s2.level === 1 && s2.xp === 10 && s2.xpForNext === 400 && approx(s2.progress, 0.025));
}
// --------------------------------------------------- the system's wiring
{
// The REAL CombatSystem (a headless scene — the same seams
// dev/combat.test.mjs exercises). A kill grants the hostile's TYPE's
// own `xp` (data/combat.json), and a crossed level fires the beat:
// the armor bump (data/progression.json → reward) + the callback.
class V2 {
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
set(x, y) { this.x = x; this.y = y; return this; }
length() { return Math.hypot(this.x, this.y); }
}
class Sprite {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame;
this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true; this.active = true;
this.body = null;
}
setScale(s) { this.scale = s; return this; }
setTint() { return this; }
setBlendMode() { return this; }
setDepth() { return this; }
setOrigin() { return this; }
setAlpha(a) { this.alpha = a; return this; }
setVisible(v) { this.visible = v; return this; }
destroy() { this.active = false; this.destroyed = true; }
}
const PhaserStub = {
Physics: { Arcade: { Sprite } },
Math: { Angle: { Wrap: (a) => { const min = -Math.PI; const r = Math.PI * 2; return (((a - min) % r) + r) % r + min; } } },
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
BlendModes: { ADD: 'ADD' },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
const makeImg = () => ({
x: 0, y: 0, scale: 1, alpha: 1, visible: true, active: true,
setScale() { return this; }, setAlpha() { return this; }, setTint() { return this; },
setBlendMode() { return this; }, setDepth() { return this; }, setAngle() { return this; },
setVisible(v) { this.visible = v; return this; }, destroy() { this.active = false; },
});
const makeGraphics = () => ({
clear() { return this; }, lineStyle() { return this; }, fillStyle() { return this; },
lineBetween() { return this; }, fillCircle() { return this; }, fillRoundedRect() { return this; },
strokeRoundedRect() { return this; }, setScrollFactor() { return this; }, setDepth() { return this; },
setVisible(v) { this.visible = v; return this; }, destroy() { this.destroyed = true; },
});
const makeTextObj = () => ({
text: '', alpha: 0, visible: true,
setText(t) { this.text = String(t); return this; }, setAlpha() { return this; },
setVisible(v) { this.visible = v; return this; }, setOrigin() { return this; },
setScrollFactor() { return this; }, setDepth() { return this; }, destroy() { this.destroyed = true; },
});
const makeScene = () => ({
textures: { exists: () => true },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: {
existing: (o) => o,
image: () => makeImg(),
graphics: makeGraphics,
text: makeTextObj,
rectangle: () => ({ setInteractive() { return { on() {} }; }, setScrollFactor() { return this; }, setDepth() { return this; } }),
},
physics: { add: { existing: (o) => { o.body = { velocity: new V2(), setCircle() {}, setAllowGravity() {} }; return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
});
const { CombatSystem } = await import(
pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href
);
const scene = makeScene();
const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0);
scene.physics.add.existing(ship);
ship.radius = 12;
ship.stop = () => {};
const levels = [];
const combat = new CombatSystem(scene, {
ship,
onLevelUp: (level) => levels.push(level),
});
// Level 0: the base armor (playerShip's), no bump yet.
check('system: level 0 = the base armor (no bump yet)',
combat.maxHull === 100 && combat.maxShields === 40 && combat.hull === 100 && combat.shields === 40);
// The roster's XP values (data/combat.json — the base's + the bomber's).
const raider = combat.spawnEnemy(200, 0, 0, 'raider');
const bomber = combat.spawnEnemy(400, 0, 1, 'bomber');
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
check("system: the raider carries the base type's xp (data)", raider.xp === baseXp);
check('system: the bomber carries its own xp (data: 25)', bomber.xp === 25);
// A kill = one grant of the hostile's type's xp (the kill path is the
// single seam — pack kills, wave kills, and rolling replacements alike).
raider.damage(999);
combat.killEnemy(raider, 1000);
check("system: a kill grants the raider's xp (10, level still 0)",
combat.xp.xp === 10 && combat.xp.level === 0 && levels.length === 0);
// Nine more base kills → the level lands: the armor bump + the beat.
for (let i = 0; i < 9; i++) {
const e = combat.spawnEnemy(200 + i * 40, 0, 10 + i);
e.damage(999);
combat.killEnemy(e, 2000 + i);
}
const hullPerLevel = Number(config.get('progression.reward.hullPerLevel', 3)) || 0;
const shieldPerLevel = Number(config.get('progression.reward.shieldPerLevel', 2)) || 0;
check('system: ten base kills → level 1 (the barometer, through the system)',
combat.xp.level === 1 && levels.length === 1 && levels[0] === 1);
check("system: the level's armor bump stacks on the base (max armor up)",
combat.maxHull === 100 + hullPerLevel && combat.maxShields === 40 + shieldPerLevel);
check('system: the level-up refills the armor (the full-heal beat)',
combat.hull === combat.maxHull && combat.shields === combat.maxShields);
check("system: stats() carries the bank (the bar's LV chip reads it)",
combat.stats().xp !== null && combat.stats().xp.level === 1);
// The bomber's kill banks ITS value (the roster sets XP-per-threat).
bomber.damage(999);
combat.killEnemy(bomber, 3000);
check("system: the bomber's kill banks 25 (toward level 2's 400)",
combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1);
// A RESTORED ledger: the level's armor applies on build — with no
// events (a load is not a level-up) and a full ship.
const saved = XpManager.fromJSON({ level: 3, xp: 500 });
const ship2 = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0);
scene.physics.add.existing(ship2);
ship2.radius = 12;
ship2.stop = () => {};
const levels2 = [];
const combat2 = new CombatSystem(scene, {
ship: ship2,
xp: saved,
onLevelUp: (level) => levels2.push(level),
});
check("system: a restored ledger's level applies its armor on build",
combat2.maxHull === 100 + 3 * hullPerLevel && combat2.maxShields === 40 + 3 * shieldPerLevel);
check('system: the restore is silent (no level-up beat on a load)',
levels2.length === 0 && combat2.hull === combat2.maxHull);
combat2.destroy();
combat.destroy();
}
// ------------------------------------------------------------------ report
if (failures > 0) {
console.error(`\n✘ ${failures} check(s) failed`);
process.exit(1);
}
console.log('\n✔ all combat XP checks passed');

View File

@ -19,6 +19,9 @@ import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
* down deconstruct the player's death (the collapse voice)
* respawn discovery the player reforming (the "new object in
* view" voice)
* levelup solar_flare_01 the combat level landing (the charge-rise
* voice — js/combat/XpManager.js's 'levelup'
* event, subscribed by CombatSystem)
*
* Playback goes through the shared Sfx.js guards (master switch, audio
* manager, cache) — a headless scene with no manager simply doesn't
@ -32,6 +35,7 @@ export const COMBAT_SFX = {
kill: ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04'],
down: ['deconstruct'],
respawn: ['discovery'],
levelup: ['solar_flare_01'],
};
// ---------------------------------------------------------------- dev log

View File

@ -4,6 +4,7 @@ import { enemyTypeConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
import { XpManager } from './XpManager.js';
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
import { combatShake } from './CamShake.js';
import { normalizeWaves, WaveRunner } from './Waves.js';
@ -19,8 +20,15 @@ const TAU = Math.PI * 2;
* It owns, per scene:
* - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*)
* - every tracer (Bullet — one-shot, life-limited)
* - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn)
* - the player's armor (data/combat.json → playerShip: hull/shields/regen/respawn,
* + the per-level bump from data/progression.json → reward)
* - the player's ENERGY (→ energy: the skills' resource — they spend it later)
* - the XP BANK (XpManager — the combat level + experience, the
* curve in data/progression.json; a kill grants the
* hostile's type `xp`, a level-up's beat — the armor
* bump, the burst, the scene's onLevelUp — rides the
* manager's 'levelup' EVENT; the skill layer subscribes
* to the same one later)
* - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the
* ship fires at the NEAREST one — the mouse steers,
* the cannon tracks; the cannon's numbers are → player.weapon)
@ -61,6 +69,8 @@ export class CombatSystem {
* onPlayerDown () => {} — the player's hull broke
* onPlayerUp () => {} — the player reformed
* onKill (enemy) => {} — a hostile went down
* onLevelUp (level) => {} — a combat level landed (the feed's line;
* the system's own FX/SFX/armor fire on the event)
*/
constructor(scene, o = {}) {
this.scene = scene;
@ -70,6 +80,7 @@ export class CombatSystem {
this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null;
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null;
this.enemies = [];
this.bullets = [];
@ -79,10 +90,35 @@ export class CombatSystem {
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). The BASE
// armor is playerShip's; the per-level bump (data/progression.json →
// reward) stacks on top of it, level by level — the placeholder
// toughness until the skill layer lands (a passive can override or
// stack these later).
const p = config.section('combat.playerShip', {});
this.maxHull = Math.max(1, Number(p.hull) || 100);
this.maxShields = Math.max(0, Number(p.shields) || 0);
this.baseHull = Math.max(1, Number(p.hull) || 100);
this.baseShields = Math.max(0, Number(p.shields) || 0);
const rw = config.section('progression.reward', {});
this.hullPerLevel = Math.max(0, Number(rw.hullPerLevel) || 0);
this.shieldPerLevel = Math.max(0, Number(rw.shieldPerLevel) || 0);
this.fullHealOnLevelUp = rw.fullHealOnLevelUp !== false;
// The XP bank (js/combat/XpManager.js — the curve is
// data/progression.json; each hostile carries its own type's value,
// combat.enemy.xp / the type's override). A scene may hand over a
// SHARED manager (o.xp — the main game's save state, restored from
// the ledger before the system builds); the manager is pure data,
// so it outlives any single scene.
this.xp = o.xp ?? new XpManager();
// A level's beat: the armor bump + the moment (the burst + the voice),
// then the scene's callback (the kill feed's line). The skill layer
// will subscribe to the manager's 'levelup' event itself — this is
// the system's share of it.
this._levelUnsub = this.xp.on('levelup', (ev) => this._onLevelUp(ev));
// Apply the CURRENT level's armor (a restored ledger's level — a load
// fires no events, so the bump lands here, not on the event), then a
// fresh scene is a fresh ship (full armor, whatever the level).
this._applyArmorToLevel();
this.hull = this.maxHull;
this.shields = this.maxShields;
this.playerRespawn = p.respawn !== false;
@ -384,6 +420,51 @@ export class CombatSystem {
}
}
/**
* Recompute the max armor for the current level (the base + the level's
* per-level bump — data/progression.json → reward) and settle the live
* armor: clamped to the new max, refilled when fullHealOnLevelUp (a
* level-up's beat is a full heal; a restored ledger's level applies the
* same way on build — the method is level-driven, so both paths share it).
*/
_applyArmorToLevel() {
const level = this.xp ? this.xp.level : 0;
this.maxHull = Math.max(1, this.baseHull + level * this.hullPerLevel);
this.maxShields = Math.max(0, this.baseShields + level * this.shieldPerLevel);
this.hull = Math.min(this.hull ?? this.maxHull, this.maxHull);
this.shields = Math.min(this.shields ?? this.maxShields, this.maxShields);
if (this.fullHealOnLevelUp) {
this.hull = this.maxHull;
this.shields = this.maxShields;
}
}
/**
* A level landed (the manager's 'levelup' event — one per level
* crossed): the armor bump (the placeholder toughness — the skill layer
* lands on the same event later), the moment (a cyan burst off the ship
* + the LEVEL callout + the voice), and the scene's callback (the kill
* feed's line — js/scenes/GameScene.js wires it).
* @param {{from:number, to:number, source:string}} ev the event payload
*/
_onLevelUp(ev) {
this._applyArmorToLevel();
if (this.ship) {
this.fx.explode(this.ship.x, this.ship.y, FX_COLORS.respawn);
this.fx.text(
this.ship.x,
this.ship.y - 44,
`LEVEL ${this.xp.level}`,
FX_COLORS.xp,
18,
900,
40,
);
}
combatSfx(this.scene, 'levelup');
if (this.onLevelUp) this.onLevelUp(this.xp.level, ev);
}
// ------------------------------------------------------------ the loop
/**
@ -797,6 +878,15 @@ export class CombatSystem {
// normal colors, never mid-flash (the reuse seam's edge case).
this.fx.releaseCraft(e);
this.kills += 1;
// COMBAT XP — the kill's reward (the hostile's own type's value —
// combat.enemy.xp / the type's override; the curve is
// data/progression.json). The +N floater lands on the wreckage, and a
// crossed level's beat rides the manager's 'levelup' event (the armor
// bump, the burst, the feed's line) — the kill path stays one grant.
if (this.xp) {
const gained = this.xp.grant(Number(e.xp) || 0, 'kill');
if (gained.gained > 0) this.fx.xp(e.x, e.y, gained.gained);
}
this.fx.explode(e.x, e.y, '#ff5d6f');
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
combatSfx(this.scene, 'kill');
@ -849,7 +939,9 @@ export class CombatSystem {
void index;
}
/** A snapshot for the combat bar (shields/hull left, energy right). */
/** A snapshot for the combat bar (shields/hull left, energy right) +
* the XP row (the xp bank's state — level, the run toward the next,
* the numbers). */
stats() {
return {
hull: this.hull,
@ -859,12 +951,17 @@ export class CombatSystem {
energy: this.energy,
maxEnergy: this.maxEnergy,
playerDown: this.playerDown,
xp: this.xp ? this.xp.stats() : null,
};
}
/** Tear the combat down (scene destroy). */
destroy() {
this.waveRun = null;
if (this._levelUnsub) {
this._levelUnsub();
this._levelUnsub = null;
}
for (const e of this.enemies) {
this.fx.releaseCraft(e); // settle flashes before the craft goes
e.destroy();
@ -875,6 +972,6 @@ export class CombatSystem {
this.fx.clear();
this.ship = null;
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
this.onPlayerUp = this.onKill = null;
this.onPlayerUp = this.onKill = this.onLevelUp = null;
}
}

View File

@ -127,6 +127,11 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
this.shields = this.maxShields;
this.dead = false;
// XP — what a kill of this craft is worth (the type's own `xp`, or
// the base enemy.xp when a type omits it; js/combat/XpManager.js's
// bank, the curve in data/progression.json).
this.xp = Math.max(0, Number(c.xp) || 10);
// 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);

View File

@ -22,6 +22,7 @@ export const FX_COLORS = {
kill: 0xff5d6f,
down: 0xff2d6f,
respawn: 0x7ce8ff,
xp: 0xffd84d, // the reward gold (the bar's energy color)
};
function drawSoftDot(ctx, size) {
@ -296,6 +297,17 @@ export class Fx {
this.text(x, y - 10, String(n), color, 15, 520, 26);
}
/**
* An XP gain (the kill's reward — js/combat/CombatSystem.js banks it on
* the kill): the points earned, floating up off the wreckage in the
* reward gold (FX_COLORS.xp) — a touch smaller + slower than a damage
* number, so it reads as a REWARD landing, not another hit.
*/
xp(x, y, amount) {
const n = Math.max(0, Math.round(Number(amount) || 0));
this.text(x, y - 24, `+${n}`, FX_COLORS.xp, 14, 750, 32);
}
// ------------------------------------------------------------ the lasers
/**

209
js/combat/XpManager.js Normal file
View File

@ -0,0 +1,209 @@
/**
* XP MANAGER — the combat level + experience bank. Pure rules, no
* Phaser, no scene (the same contract as js/combat/Combat.js — it runs
* in bare Node, dev/xp.test.mjs exercises exactly this).
*
* The curve lives in data/progression.json (the config owns the numbers,
* this owns the behavior):
*
* xpForLevel(n) = base × n^power (n = the level ADVANCED TO, 1..maxLevel)
*
* with the shipped values (maxLevel 80, base 100, power 2) that makes
* level 1 cost 100 XP — ten kills of the base hostile (its 10 XP,
* data/combat.json → enemy.xp) — the barometer. The quadratic means each
* level's cost is n²: the relative step SHRINKS as you climb (4×, 2.25×,
* 1.78× … 1.03×), so early levels fly by and the late ones never become
* a wall. Two independent dials: `power` is the grind INTENSITY (lower =
* gentler back half), `base` scales the whole curve.
*
* Enemies carry their own XP (per-type `xp` in data/combat.json →
* enemy / enemyTypes) — the curve is fixed in XP, the roster sets
* XP-per-threat, so pacing tunes without touching the other.
*
* EVENTS (the extension seam — the skill layer subscribes to the same
* ones later; listeners can be added/removed from any owner):
*
* 'grant' every grant: { amount, source, level, xp, levelsGained }
* 'levelup' per level crossed (one BIG grant crossing several fires
* one event EACH, so an "unlocks exactly at level N" rule
* can't miss): { from, to, levelsGained, source }
* 'max' once, when the cap is first reached: { level, source }
*
* A listener that throws is caught + warned — one bad subscriber can't
* wedge the kill path.
*
* CAP: the level holds at maxLevel and the XP is BANKED (xp keeps
* counting — harmless; the post-cap currency will be the skill layer,
* not XP).
*
* Save-safe: toJSON() → { level, xp } (the curve is data, so the record
* stays tiny — the "save = seed + player state" doctrine). restore()
* NEVER fires events — a load is not a level-up.
*/
import { config } from '../config/Config.js';
export class XpManager {
constructor() {
this.maxLevel = Math.max(1, Math.floor(Number(config.get('progression.maxLevel', 80)) || 80));
this.base = Math.max(0, Number(config.get('progression.base', 100)) || 0);
this.power = Math.max(0.05, Number(config.get('progression.power', 2)) || 2);
this.level = 0;
this.xp = 0; // progress toward the next level (BANKED at the cap)
this._maxAnnounced = false;
this._listeners = new Map(); // event name → Set<fn>
}
/** Level 80 (data) and holding — the XP bank keeps counting. */
get isMax() {
return this.level >= this.maxLevel;
}
/**
* The XP needed to ADVANCE TO level n (n = 1..maxLevel — the level-0
* player pays xpForLevel(1) for level 1). Pure math; n outside the
* range just computes (the CAP is the level, not the formula).
* @param {number} n the target level
* @returns {number} the XP cost (0 for n < 1)
*/
xpForLevel(n) {
n = Math.floor(Number(n) || 0);
if (n < 1) return 0;
return Math.round(this.base * Math.pow(n, this.power));
}
/** The XP from the CURRENT level to the next (null at the cap). */
xpForNext() {
if (this.isMax) return null;
return this.xpForLevel(this.level + 1);
}
/**
* Subscribe to a manager event ('grant' | 'levelup' | 'max').
* @param {string} event
* @param {(payload: object) => void} fn
* @returns {() => void} the unsubscribe (idempotent)
*/
on(event, fn) {
if (typeof fn !== 'function') return () => {};
const key = String(event || '');
if (!this._listeners.has(key)) this._listeners.set(key, new Set());
this._listeners.get(key).add(fn);
return () => this.off(event, fn);
}
/** Remove a subscriber (safe to call twice / for a stranger). */
off(event, fn) {
this._listeners.get(String(event || ''))?.delete(fn);
}
/** Fire an event to its subscribers — a throw is caught + warned. */
_emit(event, payload) {
const subs = this._listeners.get(event);
if (!subs || subs.size === 0) return;
for (const fn of [...subs]) {
try {
fn(payload);
} catch (err) {
if (typeof console !== 'undefined')
console.warn(`[orbit] xp: "${event}" listener threw`, err);
}
}
}
/**
* Bank XP. Crosses as many levels as the grant buys (one event each —
* see the class doc), caps at maxLevel (the bank keeps counting), and
* fires 'grant' last, with the settled state.
* @param {number} amount the XP earned (a hostile's type `xp`)
* @param {string} [source] the ledger's source tag ('kill' for now;
* quests/dev probes flow through the same pipe later)
* @returns {{gained:number, level:number, levelsGained:number, leveledUp:boolean}}
*/
grant(amount, source = 'kill') {
const n = Math.max(0, Math.floor(Number(amount) || 0));
if (n === 0) {
return { gained: 0, level: this.level, levelsGained: 0, leveledUp: false };
}
this.xp += n;
let levelsGained = 0;
while (this.level < this.maxLevel) {
const need = this.xpForLevel(this.level + 1);
if (this.xp < need) break;
this.xp -= need;
this.level += 1;
levelsGained += 1;
this._emit('levelup', {
from: this.level - 1,
to: this.level,
levelsGained,
source,
});
if (this.level >= this.maxLevel && !this._maxAnnounced) {
this._maxAnnounced = true;
this._emit('max', { level: this.level, source });
}
}
this._emit('grant', {
amount: n,
source,
level: this.level,
xp: Math.max(0, Math.round(this.xp)),
levelsGained,
});
return { gained: n, level: this.level, levelsGained, leveledUp: levelsGained > 0 };
}
/**
* Apply a saved { level, xp } (or reset to the fresh ledger when the
* state is absent). NO events — a load is not a level-up.
* @param {object|null} state { level, xp } (tolerant of junk)
* @returns {XpManager} this
*/
restore(state) {
const level =
state && typeof state === 'object'
? Math.max(0, Math.min(this.maxLevel, Math.floor(Number(state.level) || 0)))
: 0;
const xp =
state && typeof state === 'object' ? Math.max(0, Math.floor(Number(state.xp) || 0)) : 0;
this.level = level;
this.xp = xp;
this._maxAnnounced = level >= this.maxLevel;
return this;
}
/** The fresh run's ledger (a new game — not a level-up either). */
reset() {
this.level = 0;
this.xp = 0;
this._maxAnnounced = false;
return this;
}
/** The save's shape (tiny — the curve lives in data, not the record). */
toJSON() {
return { level: this.level, xp: Math.max(0, Math.round(this.xp)) };
}
/** A fresh manager from a save's { level, xp } (no events). */
static fromJSON(state) {
return new XpManager().restore(state);
}
/**
* A snapshot for the UI (the combat bar's XP row) / the dev console.
* `progress` = 0..1 toward the next level (1 at the cap — the bank is
* full by definition).
*/
stats() {
const next = this.xpForNext();
return {
level: this.level,
xp: Math.max(0, Math.round(this.xp)),
xpForNext: next,
maxLevel: this.maxLevel,
isMax: this.isMax,
progress: next ? Math.max(0, Math.min(1, this.xp / next)) : 1,
};
}
}

View File

@ -36,6 +36,14 @@
* // themselves regenerate from
* // the seed — js/quests/
* // SideQuests.js)
* combat: { done: [scriptName, …], // the CLEARED wave scripts
* level, xp }, // + the run's COMBAT XP
* // ledger (js/combat/
* // XpManager.js — the
* // level + the xp banked
* // toward the next; the
* // curve is data/
* // progression.json)
* totalMined: number, // lifetime asteroid mining
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
@ -241,8 +249,17 @@ export function captureState(scene, now) {
// re-arms the ambush on load, but a cleared one never re-fires).
// The ledger lives in the registry ('combatDone'). A save predating
// it has no field; the restore stages an empty set (old saves
// load — the step simply hasn't earned its witness yet).
combat: { done: Array.from(scene.registry.get('combatDone') ?? []) },
// load — the step simply hasn't earned its witness yet). PLUS the
// run's COMBAT XP ledger (js/combat/XpManager.js — the curve is
// data/progression.json): level + the xp banked toward the next
// (at the cap, the banked overflow — harmless). A save predating
// it has no fields; the restore stages a fresh ledger (old saves
// load — the run starts at level 0).
combat: {
done: Array.from(scene.registry.get('combatDone') ?? []),
level: Math.max(0, Math.floor(Number(scene.combat?.xp?.level) || 0)),
xp: Math.max(0, Math.round(Number(scene.combat?.xp?.xp) || 0)),
},
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the record).
// A save predating it has no field; the restore stages 0.
@ -459,6 +476,20 @@ export function prepareLoad(registry, record) {
: [],
),
);
// The run's COMBAT XP ledger (js/combat/XpManager.js — level + the xp
// banked toward the next). A save predating it has no fields → null
// (a fresh run starts at level 0; old saves load). Normalized so a
// hand-edited record can't leak a bad shape into the scene (the
// manager's restore re-clamps anyway).
registry.set(
'combatXp',
Number(record.combat?.level) > 0 || Number(record.combat?.xp) > 0
? {
level: Math.max(0, Math.floor(Number(record.combat?.level) || 0)),
xp: Math.max(0, Math.floor(Number(record.combat?.xp) || 0)),
}
: null,
);
// The run's VIGNETTES played-ledger (a save predating vignettes has no
// field → an empty set — old saves load; a fresh run's scenes arm on
// their quest gates in data/vignettes.json).
@ -512,6 +543,7 @@ export function resetRunState(registry) {
registry.set('commsPlayed', null); // the welcome arms again on the new run
registry.set('commsAcked', null); // the ack gates re-arm on the new run
registry.set('combatDone', null); // the cleared waves re-arm on the new run
registry.set('combatXp', null); // the new run's level + xp stand fresh
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -36,6 +36,7 @@ import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { CombatBar } from '../ui/CombatBar.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { XpManager } from '../combat/XpManager.js';
import { CombatHud } from '../combat/CombatHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
@ -977,13 +978,23 @@ export class GameScene extends Phaser.Scene {
onCast: (i) => this.combat?.cast(i),
});
this.combatBar.setVisible(false);
// The run's COMBAT XP ledger (js/combat/XpManager.js — the curve is
// data/progression.json; each hostile's type carries its own `xp`,
// data/combat.json → enemy.xp / enemyTypes). A save predating the
// ledger has no field → the run starts at level 0 (old saves load).
const savedXp = this.registry.get('combatXp');
this.combat = new CombatSystem(this, {
ship: this.ship,
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
onActiveChange: (active) => this._setCombatBar(active),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the
// feed, and a hit flashes the bar's gauge that took it.
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
// A combat level landed (the system fires its own burst + voice +
// the armor bump on the manager's 'levelup' event — the skill
// layer will subscribe to that same event later): the feed's line.
onLevelUp: (level) => this.combatHud?.kill(`LEVEL ${level} — XP`),
onPlayerDamage: (n, a) => {
if (!this.combatHud || !a) return;
if (a.shieldHit > 0) this.combatHud.flash('shield');
@ -1048,6 +1059,11 @@ export class GameScene extends Phaser.Scene {
},
damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
cast: (i) => this.combat?.cast(i),
// COMBAT XP (js/combat/XpManager.js): read the bank (level, the
// xp toward the next, the cap), or feed it straight to test the
// curve / the level-up beat (source 'dev' — the ledger tags it).
xp: () => this.combat?.xp?.stats() ?? null,
grantXp: (n = 100) => this.combat?.xp?.grant(n, 'dev') ?? null,
// 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's own hook is live (the 'Pirate

View File

@ -1,5 +1,6 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { XpManager } from '../combat/XpManager.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack } from '../utils/Theme.js';
import { canvasTexture } from '../utils/Textures.js';
@ -14,6 +15,11 @@ function mixWhite(n, t) {
return (m((n >> 16) & 255) << 16) | (m((n >> 8) & 255) << 8) | m(n & 255);
}
/** Thousands-separate an XP count (the XP row's numbers column). */
function fmtXp(n) {
return Math.round(Math.max(0, Number(n) || 0)).toLocaleString('en-US');
}
/**
* The soft radial dot (white, 64px) the orb shading layers use — a
* white-tinted copy reads as highlight, a black tint as shadow. Cached
@ -41,10 +47,24 @@ function orbDotKey(scene) {
* wiped). The concept (assets/bar.png — a storyboard, NOT a color ref):
*
* (HULL orb + SHIELD orb) [1][2][3][4][5][6][7][8] (ENERGY orb)
* ────────────────────────────────────────────────────────────────────
* ┌─ LV n ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ a / b XP ─┐
* └──────────── [1]'s left edge ──────────── [8]'s right edge ─────────┘
*
* left — the hull ORB (red) with the SHIELD orb (cyan, semi-
* transparent) concentric over it: the shield sits IN FRONT of
* the hull, so the red reads through the cyan.
* bottom — the XP bar (the combat level + bank — data/progression.json,
* the xp gold): ONE full bar spanning the skill seats' length
* (the number buttons' row), the summary embedded — "LV n"
* on the left edge, the numbers on the right (banked XP over
* the next level's cost; the banked total at the cap, where
* the bar holds full with a soft glow and the xp keeps
* counting — the seam for the post-cap currency). Each text
* rides a soft dark chip (sized to the text) so the gold
* reads over the gold fill as it does over the track; the
* fill wears the orbs' glass idiom (sheen + meniscus edge).
* A snapshot without xp state (the range's bar) hides the row.
* middle — the eight SKILL seats, wearing keys 1-8. The skills land
* later — for now they are empty seats (a press lands on
* o.onCast(index); the system's cast() is the no-op stub).
@ -128,9 +148,19 @@ export class CombatBar extends Phaser.GameObjects.Container {
const sR = R * shScale;
const SW = n * S + (n - 1) * G;
const x0 = (W - totalW) / 2;
const stripTop = H - mB - stripH;
const stripBottom = H - mB;
// The XP row's band (the bar grows DOWNWARD from the orbs' row —
// the panel's bottom edge stays put, the top grows): the separator
// hairline + the bar's band (the bar is tall enough for its
// embedded summary text).
const sepBandH = 14;
const xpBandH = 24;
const panelBottom = H - mB;
const panelTop = panelBottom - (stripH + sepBandH + xpBandH);
const stripTop = panelTop;
const stripBottom = panelTop + stripH; // the orbs' row (the original strip)
const cY = (stripTop + stripBottom) / 2;
const sepY = stripBottom + sepBandH / 2;
const xpRowCY = stripBottom + sepBandH + xpBandH / 2;
const hull = { cx: x0 + edgePad + R, cy: cY, r: R };
const shield = { cx: hull.cx + shOX * R, cy: cY + shOY * R, r: sR };
@ -138,14 +168,17 @@ export class CombatBar extends Phaser.GameObjects.Container {
const eCx = firstSlotL + SW + orbGap + R;
const x1 = eCx + R + edgePad;
// ---- the strip (the panel the seats sit in) -----------------------
// ---- the panel (the strip the seats sit in + the XP row below) ----
const panelH = panelBottom - panelTop;
const panel = scene.add.graphics();
panel.fillStyle(bg, bgAlpha);
panel.fillRoundedRect(x0, stripTop, x1 - x0, stripH, Math.min(12, stripH * 0.2));
panel.fillRoundedRect(x0, panelTop, x1 - x0, panelH, Math.min(12, panelH * 0.1));
panel.lineStyle(2, edge, 0.55); // the top rule (the concept's double line)
panel.lineBetween(x0 + 12, stripTop + 1.5, x1 - 12, stripTop + 1.5);
panel.lineBetween(x0 + 12, panelTop + 1.5, x1 - 12, panelTop + 1.5);
panel.lineStyle(1.5, edge, 0.4);
panel.lineBetween(x0 + 12, stripBottom - 1.5, x1 - 12, stripBottom - 1.5);
panel.lineBetween(x0 + 12, panelBottom - 1.5, x1 - 12, panelBottom - 1.5);
panel.lineStyle(1, edge, 0.3); // the separator (the orbs' row / the XP row)
panel.lineBetween(x0 + 12, sepY, x1 - 12, sepY);
panel.setScrollFactor(0).setDepth(DEPTH);
this.add(panel);
@ -239,8 +272,95 @@ export class CombatBar extends Phaser.GameObjects.Container {
// ---- the energy orb (right end) -----------------------------------
this.orbEnergy = mkOrb(eCx, cY, R, energyC, 0.95);
// ---- the XP row (below the orbs' row — the level + the bank) -----
// The combat level + the XP bank (data/progression.json — the row is
// the xp gold, the progression currency's color). ONE full bar
// spanning the skill seats' length (the number buttons' row), the
// summary embedded in it:
//
// LV n ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ a / b XP
//
// "LV n" rides the bar's left edge, the numbers the right — banked
// XP over the next level's cost; at the cap the bar holds full (a
// soft glow) and the numbers read the banked total (the xp keeps
// counting — the seam for the post-cap currency, when the skills
// land). Each sits on a soft dark chip (sized to the text, redrawn
// when it changes) so the gold reads over the gold fill as it does
// over the track. The fill wears the orbs' glass idiom (sheen + the
// meniscus line at its edge).
const xpGold = toColor(c.energy, 0xffd84d);
const xpCss = toCss(c.energy, '#ffd84d');
const _cap = new XpManager(); // data-driven — the level-1 cost
const barX = firstSlotL; // the seats' left edge — the bar spans their row
const barW = SW; // the number buttons' full length
const barH = 18; // tall enough for the embedded summary text
const barY = xpRowCY - barH / 2;
const xpTrack = scene.add.graphics();
xpTrack.fillStyle(0x0a121d, 0.9); // the track (a step darker than the panel)
xpTrack.fillRoundedRect(barX, barY, barW, barH, barH / 2);
xpTrack.lineStyle(1, xpGold, 0.5); // the gold keyline
xpTrack.strokeRoundedRect(barX + 0.5, barY + 0.5, barW - 1, barH - 1, (barH - 1) / 2);
xpTrack.setScrollFactor(0).setDepth(DEPTH).setVisible(false); // hidden until a snapshot carries xp
this.add(xpTrack);
const xpFill = scene.add.graphics().setScrollFactor(0).setDepth(DEPTH).setVisible(false);
this.add(xpFill);
const xpLabelChip = scene.add.graphics()
.setScrollFactor(0).setDepth(DEPTH).setVisible(false);
xpLabelChip.setPosition(barX + 10, xpRowCY);
this.add(xpLabelChip);
const xpLabel = scene.add
.text(barX + 10, xpRowCY, 'LV 0', {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: '13px',
fontStyle: 'bold',
color: xpCss,
stroke: '#02131c',
strokeThickness: 2.5,
})
.setOrigin(0, 0.5)
.setScrollFactor(0)
.setDepth(DEPTH)
.setVisible(false);
this.add(xpLabel);
this._refreshXpChip(xpLabelChip, xpLabel);
const startNums = `0 / ${fmtXp(_cap.xpForLevel(1))} XP`;
const xpNumbersChip = scene.add.graphics()
.setScrollFactor(0).setDepth(DEPTH).setVisible(false);
xpNumbersChip.setPosition(barX + barW - 10, xpRowCY);
this.add(xpNumbersChip);
const xpNumbers = scene.add
.text(barX + barW - 10, xpRowCY, startNums, {
fontFamily: fontStack('body', FONT_FALLBACK),
fontSize: '11px',
color: xpCss,
stroke: '#02131c',
strokeThickness: 2,
})
.setOrigin(1, 0.5)
.setScrollFactor(0)
.setDepth(DEPTH)
.setVisible(false);
this.add(xpNumbers);
this._refreshXpChip(xpNumbersChip, xpNumbers);
this.xpBar = { x: barX, y: barY, w: barW, h: barH, color: xpGold, fill: xpFill, frac: -1 };
this.xpLabel = xpLabel;
this.xpLabelChip = xpLabelChip;
this.xpNumbers = xpNumbers;
this.xpNumbersChip = xpNumbersChip;
this.xpTrack = xpTrack;
this._xpLabel = 'LV 0';
this._xpNumbers = startNums;
this._xpShown = false;
this._xpMax = false;
// The bar's footprint (contains()'s click gate) — the orbs overhang
// the strip, so their bounds dominate.
// the panel, so their bounds dominate (the XP row below is
// display-only — no clicks to gate).
const topY = Math.min(hull.cy - hull.r, shield.cy - shield.r);
const botY = Math.max(hull.cy + hull.r, shield.cy + shield.r);
this.rect = {
@ -264,6 +384,82 @@ export class CombatBar extends Phaser.GameObjects.Container {
this._setOrb(this.orbHull, frac(s.hull, s.maxHull));
this._setOrb(this.orbShield, frac(s.shields, s.maxShields));
this._setOrb(this.orbEnergy, frac(s.energy, s.maxEnergy));
// The XP bar (the level + the bank — a snapshot without xp state
// hides it, so a bar fed a bare armor/energy snapshot — the range —
// keeps its old face).
if (this.xpLabel) {
const row = [this.xpLabel, this.xpLabelChip, this.xpNumbers, this.xpNumbersChip, this.xpTrack, this.xpBar?.fill];
if (s.xp) {
if (!this._xpShown) {
this._xpShown = true;
for (const o of row) o?.setVisible(true);
}
const { level, xp, xpForNext, isMax } = s.xp;
const label = isMax ? `LV ${level} MAX` : `LV ${level}`;
if (label !== this._xpLabel) {
this._xpLabel = label;
this.xpLabel.setText(label);
this._refreshXpChip(this.xpLabelChip, this.xpLabel);
}
const numbers = isMax
? `XP ${fmtXp(xp)} banked`
: `${fmtXp(xp)} / ${fmtXp(xpForNext)} XP`;
if (numbers !== this._xpNumbers) {
this._xpNumbers = numbers;
this.xpNumbers.setText(numbers);
this._refreshXpChip(this.xpNumbersChip, this.xpNumbers);
}
this._drawXpBar(isMax ? 1 : (s.xp.progress ?? 0), isMax);
} else if (this._xpShown) {
this._xpShown = false;
for (const o of row) o?.setVisible(false);
}
}
}
/**
* The embedded summary's dark chip — sized to the text it sits under,
* centered on the text's position (the text's own anchor is
* irrelevant — the chip is symmetric).
*/
_refreshXpChip(chip, t) {
if (!chip || !t) return;
chip.clear();
chip.fillStyle(0x02131c, 0.55);
chip.fillRoundedRect(-t.width / 2 - 5, -t.height / 2 - 2.5, t.width + 10, t.height + 5, 4);
}
/**
* The XP bar's fill (epsilon-guarded, like the orbs' liquids): the
* gold column, the glass sheen, the meniscus line at the fill's edge —
* and at the cap a soft outer glow (the bar is full and holding).
*/
_drawXpBar(f, isMax) {
const b = this.xpBar;
if (!b) return;
f = Math.max(0, Math.min(1, Number(f) || 0));
if (Math.abs(f - b.frac) < 0.002 && isMax === this._xpMax) return;
b.frac = f;
this._xpMax = isMax;
const g = b.fill;
g.clear();
if (f <= 0.004) return;
const x = b.x + 1.5;
const y = b.y + 1.5;
const h = b.h - 3;
const fw = Math.max(h, (b.w - 3) * f);
g.fillStyle(b.color, 0.95);
g.fillRoundedRect(x, y, fw, h, h / 2);
g.fillStyle(0xffffff, 0.2); // the sheen (the orbs' glass, top stripe)
g.fillRoundedRect(x + 2, y + 1, Math.max(1, fw - 4), Math.max(1.2, h * 0.3), 1.2);
if (f < 0.999) {
g.fillStyle(0xffffff, 0.55); // the meniscus — the fill's leading edge
g.fillRoundedRect(x + fw - 2.5, y, 2.5, h, 1.25);
}
if (isMax) {
g.fillStyle(b.color, 0.22); // the cap: a soft glow around the full bar
g.fillRoundedRect(x - 1.5, y - 1.5, fw + 3, h + 3, (h + 3) / 2);
}
}
_setOrb(orb, f) {
@ -342,6 +538,12 @@ export class CombatBar extends Phaser.GameObjects.Container {
/** Tear down (scene destroy) — the children go with the scene anyway. */
destroy() {
this.onCast = null;
this.xpLabel = null;
this.xpLabelChip = null;
this.xpNumbers = null;
this.xpNumbersChip = null;
this.xpTrack = null;
this.xpBar = null;
super.destroy();
}
}