diff --git a/README.md b/README.md index 943bb3a..786c7bd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/data/combat.json b/data/combat.json index 85c6198..3472a88 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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, diff --git a/data/manifest.json b/data/manifest.json index fba72a5..67ab4bc 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -37,6 +37,7 @@ "goods.json", "economy.json", "shopui.json", - "combat.json" + "combat.json", + "progression.json" ] } diff --git a/data/progression.json b/data/progression.json new file mode 100644 index 0000000..9389b2b --- /dev/null +++ b/data/progression.json @@ -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 + } +} diff --git a/dev/combat-shot.mjs b/dev/combat-shot.mjs index 261dd5e..24ca693 100644 --- a/dev/combat-shot.mjs +++ b/dev/combat-shot.mjs @@ -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`); diff --git a/dev/combat.sfxcheck.mjs b/dev/combat.sfxcheck.mjs index 3de7601..2c96c36 100644 --- a/dev/combat.sfxcheck.mjs +++ b/dev/combat.sfxcheck.mjs @@ -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'); diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 0a479f1..67fb7ac 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -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')); diff --git a/dev/shots/combat-main.png b/dev/shots/combat-main.png index a735b98..82bd975 100644 Binary files a/dev/shots/combat-main.png and b/dev/shots/combat-main.png differ diff --git a/dev/shots/combat-range.png b/dev/shots/combat-range.png index db5c89f..7aae4e4 100644 Binary files a/dev/shots/combat-range.png and b/dev/shots/combat-range.png differ diff --git a/dev/xp.test.mjs b/dev/xp.test.mjs new file mode 100644 index 0000000..d4fe448 --- /dev/null +++ b/dev/xp.test.mjs @@ -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'); diff --git a/js/combat/CombatSfx.js b/js/combat/CombatSfx.js index 0457080..e5fe4eb 100644 --- a/js/combat/CombatSfx.js +++ b/js/combat/CombatSfx.js @@ -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 diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 7bec301..45512b4 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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; } } diff --git a/js/combat/EnemyShip.js b/js/combat/EnemyShip.js index ac1bc64..c361004 100644 --- a/js/combat/EnemyShip.js +++ b/js/combat/EnemyShip.js @@ -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); diff --git a/js/combat/Fx.js b/js/combat/Fx.js index 0a26756..ccb252d 100644 --- a/js/combat/Fx.js +++ b/js/combat/Fx.js @@ -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 /** diff --git a/js/combat/XpManager.js b/js/combat/XpManager.js new file mode 100644 index 0000000..0f9c20c --- /dev/null +++ b/js/combat/XpManager.js @@ -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 + } + + /** 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, + }; + } +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 367ef0b..c3222b9 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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: [":"], // 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); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index e4db4f9..b1195eb 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/ui/CombatBar.js b/js/ui/CombatBar.js index 692d442..38c85a3 100644 --- a/js/ui/CombatBar.js +++ b/js/ui/CombatBar.js @@ -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(); } }