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:
parent
a690ca74a2
commit
b461bc98af
38
README.md
38
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
|
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
|
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
|
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,
|
`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
|
- **Feel** — the handling is what the range tunes. Every feel number
|
||||||
(thrust, top speed, drag, turn rate + turn response, throttle ramp/
|
(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
|
**player goes down**, and a REFORMED bloom on **respawn**. Shield
|
||||||
reads cyan and hull reads orange in both the flash and the number, so
|
reads cyan and hull reads orange in both the flash and the number, so
|
||||||
a glance tells you which layer took the hit.
|
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
|
from the existing `data/sfx.json` set (no new sound files), through
|
||||||
one shared event→voice map + playback path (`js/combat/CombatSfx.js`)
|
one shared event→voice map + playback path (`js/combat/CombatSfx.js`)
|
||||||
so the two fronts can't drift: firing = `scan`, impact = `ui_click`,
|
so the two fronts can't drift: firing = `scan`, impact = `ui_click`,
|
||||||
shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04`
|
shield = `solar_buzz`, hull = `mining_split`, kill = `thunder_01..04`
|
||||||
(one at random), down = `deconstruct`, respawn = `discovery`. Enemy
|
(one at random), down = `deconstruct`, respawn = `discovery`,
|
||||||
fire reuses the `scan` voice, quieter.
|
level-up = `solar_flare_01`. Enemy fire reuses the `scan` voice,
|
||||||
|
quieter.
|
||||||
- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure
|
- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure
|
||||||
rules in `js/combat/Combat.js`) makes the fight *readable* in both
|
rules in `js/combat/Combat.js`) makes the fight *readable* in both
|
||||||
fronts: a rotating **lock reticle** on the current target (the same
|
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),
|
hostiles outside the camera (the main game's camera follows the ship),
|
||||||
a **kill feed** (newest-first, top-right), and a **gauge flash** — the
|
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 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
|
- **Dev hooks + checkers** — besides the two pokes above, every SFX call
|
||||||
is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the
|
is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the
|
||||||
wiring is verifiable headless in both fronts. CDP checkers drive the
|
wiring is verifiable headless in both fronts. CDP checkers drive the
|
||||||
real game and read state back: `node dev/combat-flycheck.mjs`
|
real game and read state back: `node dev/combat-flycheck.mjs`
|
||||||
(flight feel — t60/t30, spin, stop distance, top speed),
|
(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
|
shared path, in both the range and the main game), and
|
||||||
`node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame
|
`node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame
|
||||||
per context through the production paths into `dev/shots/` — the
|
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
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── 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
|
│ ├── 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
|
│ ├── 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
|
│ ├── 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)
|
│ ├── main.js # entry point: load config → boot Phaser (?combat boots the normal game with the combat demo live)
|
||||||
│ ├── config/ # Config singleton, ConfigLoader, game config
|
│ ├── config/ # Config singleton, ConfigLoader, game config
|
||||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
│ ├── 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)
|
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
|
||||||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
|
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
|
||||||
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
|
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"enemy": {
|
"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",
|
"texture": "assets/images/ships-enemy.png",
|
||||||
"frameWidth": 128,
|
"frameWidth": 128,
|
||||||
"frameHeight": 128,
|
"frameHeight": 128,
|
||||||
|
|
@ -60,6 +60,7 @@
|
||||||
"orbitSpeed": 0.6,
|
"orbitSpeed": 0.6,
|
||||||
"hull": 60,
|
"hull": 60,
|
||||||
"shields": 0,
|
"shields": 0,
|
||||||
|
"xp": 10,
|
||||||
"weapon": {
|
"weapon": {
|
||||||
"range": 560,
|
"range": 560,
|
||||||
"cooldown": 1.4,
|
"cooldown": 1.4,
|
||||||
|
|
@ -81,7 +82,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"enemyTypes": {
|
"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": {
|
"raider": {
|
||||||
"frame": 0
|
"frame": 0
|
||||||
},
|
},
|
||||||
|
|
@ -91,6 +92,7 @@
|
||||||
"turn": 2.2,
|
"turn": 2.2,
|
||||||
"hull": 90,
|
"hull": 90,
|
||||||
"shields": 20,
|
"shields": 20,
|
||||||
|
"xp": 25,
|
||||||
"weapon": {
|
"weapon": {
|
||||||
"range": 720,
|
"range": 720,
|
||||||
"cooldown": 1.8,
|
"cooldown": 1.8,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"goods.json",
|
"goods.json",
|
||||||
"economy.json",
|
"economy.json",
|
||||||
"shopui.json",
|
"shopui.json",
|
||||||
"combat.json"
|
"combat.json",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@
|
||||||
* flash + damage number, kill burst + feed,
|
* flash + damage number, kill burst + feed,
|
||||||
* player-hit flash + gauge flash
|
* player-hit flash + gauge flash
|
||||||
* combat-main.png — index.html?combat: the same set in the
|
* 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 /
|
* Every event goes through the shared production code (CombatSfx / Fx /
|
||||||
* CombatHud), so the frame is a faithful picture of what a player sees.
|
* 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()); }
|
if (e) { e.shields = 0; e.damage(9999); sys.killEnemy(e, sys._now()); }
|
||||||
sys.shields = 40; sys.hull = 100;
|
sys.shields = 40; sys.hull = 100;
|
||||||
sys.damagePlayer(8);
|
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;
|
return true;
|
||||||
})()`);
|
})()`);
|
||||||
await p.shot(`${OUT}/combat-main.png`);
|
await p.shot(`${OUT}/combat-main.png`);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
* (http://127.0.0.1:8080/index.html?combat) — and asserts that each of
|
* (http://127.0.0.1:8080/index.html?combat) — and asserts that each of
|
||||||
* the seven combat events (fire / impact / shield / hull / kill /
|
* the seven combat events (fire / impact / shield / hull / kill /
|
||||||
* down / respawn) went through the SHARED SFX path
|
* 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
|
* 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
|
* 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 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 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) {
|
function connect(target) {
|
||||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
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
|
* Drive the seven events in `scope` (a JS expression resolving to a
|
||||||
* scope object) and return the distinct events observed in the dev log.
|
* scope object) and return the distinct events observed in the dev log.
|
||||||
*/
|
*/
|
||||||
function driveScript(scopeExpr) {
|
function driveScript(scopeExpr, withLevelUp = false) {
|
||||||
return `(async () => {
|
return `(async () => {
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
const scope = ${scopeExpr};
|
const scope = ${scopeExpr};
|
||||||
if (!scope) return 'no scope';
|
if (!scope) return 'no scope';
|
||||||
|
const driveLevelUp = ${Boolean(withLevelUp)}; // injected at build time
|
||||||
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
|
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
|
||||||
|
|
||||||
// fire — the cannon goes off (player side, full voice).
|
// fire — the cannon goes off (player side, full voice).
|
||||||
|
|
@ -88,6 +92,11 @@ function driveScript(scopeExpr) {
|
||||||
// kill — a hostile goes down.
|
// kill — a hostile goes down.
|
||||||
scope.killOne();
|
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.
|
// down — the player's hull breaks.
|
||||||
scope.killPlayer();
|
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);
|
const { c, target } = await openPage(path);
|
||||||
try {
|
try {
|
||||||
// Give the game time to boot (assets, fonts).
|
// 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');
|
if (!scoped) throw new Error('combat scope never appeared');
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
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}`);
|
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(', ')}`);
|
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;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`${name}: FAIL — ${err.message}`);
|
console.error(`${name}: FAIL — ${err.message}`);
|
||||||
|
|
@ -188,11 +197,17 @@ const scopeMain = `(() => {
|
||||||
sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999);
|
sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999);
|
||||||
},
|
},
|
||||||
respawn: () => sys.revivePlayer(sys._now()),
|
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 = [];
|
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(
|
results.push(await checkContext(
|
||||||
'main (index.html?combat)',
|
'main (index.html?combat)',
|
||||||
'/index.html?combat',
|
'/index.html?combat',
|
||||||
|
|
@ -206,10 +221,12 @@ results.push(await checkContext(
|
||||||
}
|
}
|
||||||
return 'menu never ready';
|
return 'menu never ready';
|
||||||
})()`,
|
})()`,
|
||||||
|
EVENTS_MAIN,
|
||||||
|
true,
|
||||||
));
|
));
|
||||||
|
|
||||||
if (results.every(Boolean)) {
|
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);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
console.log('combat SFX wiring: FAIL');
|
console.log('combat SFX wiring: FAIL');
|
||||||
|
|
|
||||||
|
|
@ -610,9 +610,9 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
||||||
};
|
};
|
||||||
const sfxNames = config.get('sfx') ?? {};
|
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(',') ===
|
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',
|
check('sfx: every mapped voice exists in data/sfx.json',
|
||||||
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
|
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 |
|
|
@ -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');
|
||||||
|
|
@ -19,6 +19,9 @@ import { playSfxOn, playSfxOneOfOn } from '../utils/Sfx.js';
|
||||||
* down deconstruct the player's death (the collapse voice)
|
* down deconstruct the player's death (the collapse voice)
|
||||||
* respawn discovery the player reforming (the "new object in
|
* respawn discovery the player reforming (the "new object in
|
||||||
* view" voice)
|
* 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
|
* Playback goes through the shared Sfx.js guards (master switch, audio
|
||||||
* manager, cache) — a headless scene with no manager simply doesn't
|
* 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'],
|
kill: ['thunder_01', 'thunder_02', 'thunder_03', 'thunder_04'],
|
||||||
down: ['deconstruct'],
|
down: ['deconstruct'],
|
||||||
respawn: ['discovery'],
|
respawn: ['discovery'],
|
||||||
|
levelup: ['solar_flare_01'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- dev log
|
// ---------------------------------------------------------------- dev log
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { enemyTypeConfig } from './EnemyTypes.js';
|
||||||
import { Bullet } from './Bullet.js';
|
import { Bullet } from './Bullet.js';
|
||||||
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
|
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
|
||||||
import { applyDamage, inView, pickTarget } from './Combat.js';
|
import { applyDamage, inView, pickTarget } from './Combat.js';
|
||||||
|
import { XpManager } from './XpManager.js';
|
||||||
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
|
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
|
||||||
import { combatShake } from './CamShake.js';
|
import { combatShake } from './CamShake.js';
|
||||||
import { normalizeWaves, WaveRunner } from './Waves.js';
|
import { normalizeWaves, WaveRunner } from './Waves.js';
|
||||||
|
|
@ -19,8 +20,15 @@ const TAU = Math.PI * 2;
|
||||||
* It owns, per scene:
|
* It owns, per scene:
|
||||||
* - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*)
|
* - the hostile pack (EnemyShip — orbit ring + lance, data/combat.json → enemy.*)
|
||||||
* - every tracer (Bullet — one-shot, life-limited)
|
* - 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 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
|
* - the AUTO-FIRE (→ game.autoFire: while a hostile is in range, the
|
||||||
* ship fires at the NEAREST one — the mouse steers,
|
* ship fires at the NEAREST one — the mouse steers,
|
||||||
* the cannon tracks; the cannon's numbers are → player.weapon)
|
* the cannon tracks; the cannon's numbers are → player.weapon)
|
||||||
|
|
@ -61,6 +69,8 @@ export class CombatSystem {
|
||||||
* onPlayerDown () => {} — the player's hull broke
|
* onPlayerDown () => {} — the player's hull broke
|
||||||
* onPlayerUp () => {} — the player reformed
|
* onPlayerUp () => {} — the player reformed
|
||||||
* onKill (enemy) => {} — a hostile went down
|
* 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 = {}) {
|
constructor(scene, o = {}) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
|
@ -70,6 +80,7 @@ export class CombatSystem {
|
||||||
this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null;
|
this.onPlayerDown = typeof o.onPlayerDown === 'function' ? o.onPlayerDown : null;
|
||||||
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
|
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
|
||||||
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
|
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
|
||||||
|
this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null;
|
||||||
|
|
||||||
this.enemies = [];
|
this.enemies = [];
|
||||||
this.bullets = [];
|
this.bullets = [];
|
||||||
|
|
@ -79,10 +90,35 @@ export class CombatSystem {
|
||||||
this.waveRun = null;
|
this.waveRun = null;
|
||||||
this._spawnSerial = 0; // the orbit-dir alternation counter (pack + waves)
|
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', {});
|
const p = config.section('combat.playerShip', {});
|
||||||
this.maxHull = Math.max(1, Number(p.hull) || 100);
|
this.baseHull = Math.max(1, Number(p.hull) || 100);
|
||||||
this.maxShields = Math.max(0, Number(p.shields) || 0);
|
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.hull = this.maxHull;
|
||||||
this.shields = this.maxShields;
|
this.shields = this.maxShields;
|
||||||
this.playerRespawn = p.respawn !== false;
|
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
|
// ------------------------------------------------------------ the loop
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -797,6 +878,15 @@ export class CombatSystem {
|
||||||
// normal colors, never mid-flash (the reuse seam's edge case).
|
// normal colors, never mid-flash (the reuse seam's edge case).
|
||||||
this.fx.releaseCraft(e);
|
this.fx.releaseCraft(e);
|
||||||
this.kills += 1;
|
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');
|
this.fx.explode(e.x, e.y, '#ff5d6f');
|
||||||
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
|
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
|
||||||
combatSfx(this.scene, 'kill');
|
combatSfx(this.scene, 'kill');
|
||||||
|
|
@ -849,7 +939,9 @@ export class CombatSystem {
|
||||||
void index;
|
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() {
|
stats() {
|
||||||
return {
|
return {
|
||||||
hull: this.hull,
|
hull: this.hull,
|
||||||
|
|
@ -859,12 +951,17 @@ export class CombatSystem {
|
||||||
energy: this.energy,
|
energy: this.energy,
|
||||||
maxEnergy: this.maxEnergy,
|
maxEnergy: this.maxEnergy,
|
||||||
playerDown: this.playerDown,
|
playerDown: this.playerDown,
|
||||||
|
xp: this.xp ? this.xp.stats() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear the combat down (scene destroy). */
|
/** Tear the combat down (scene destroy). */
|
||||||
destroy() {
|
destroy() {
|
||||||
this.waveRun = null;
|
this.waveRun = null;
|
||||||
|
if (this._levelUnsub) {
|
||||||
|
this._levelUnsub();
|
||||||
|
this._levelUnsub = null;
|
||||||
|
}
|
||||||
for (const e of this.enemies) {
|
for (const e of this.enemies) {
|
||||||
this.fx.releaseCraft(e); // settle flashes before the craft goes
|
this.fx.releaseCraft(e); // settle flashes before the craft goes
|
||||||
e.destroy();
|
e.destroy();
|
||||||
|
|
@ -875,6 +972,6 @@ export class CombatSystem {
|
||||||
this.fx.clear();
|
this.fx.clear();
|
||||||
this.ship = null;
|
this.ship = null;
|
||||||
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
|
this.onActiveChange = this.onPlayerDamage = this.onPlayerDown =
|
||||||
this.onPlayerUp = this.onKill = null;
|
this.onPlayerUp = this.onKill = this.onLevelUp = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,11 @@ export class EnemyShip extends Phaser.Physics.Arcade.Sprite {
|
||||||
this.shields = this.maxShields;
|
this.shields = this.maxShields;
|
||||||
this.dead = false;
|
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).
|
// Chase feel (the type's numbers).
|
||||||
this.speed = Math.max(0, Number(c.speed) ?? 165);
|
this.speed = Math.max(0, Number(c.speed) ?? 165);
|
||||||
this.turn = Math.max(0.1, Number(c.turn) ?? 3.2);
|
this.turn = Math.max(0.1, Number(c.turn) ?? 3.2);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const FX_COLORS = {
|
||||||
kill: 0xff5d6f,
|
kill: 0xff5d6f,
|
||||||
down: 0xff2d6f,
|
down: 0xff2d6f,
|
||||||
respawn: 0x7ce8ff,
|
respawn: 0x7ce8ff,
|
||||||
|
xp: 0xffd84d, // the reward gold (the bar's energy color)
|
||||||
};
|
};
|
||||||
|
|
||||||
function drawSoftDot(ctx, size) {
|
function drawSoftDot(ctx, size) {
|
||||||
|
|
@ -296,6 +297,17 @@ export class Fx {
|
||||||
this.text(x, y - 10, String(n), color, 15, 520, 26);
|
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
|
// ------------------------------------------------------------ the lasers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,14 @@
|
||||||
* // themselves regenerate from
|
* // themselves regenerate from
|
||||||
* // the seed — js/quests/
|
* // the seed — js/quests/
|
||||||
* // SideQuests.js)
|
* // 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
|
* totalMined: number, // lifetime asteroid mining
|
||||||
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||||
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
|
* 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).
|
// re-arms the ambush on load, but a cleared one never re-fires).
|
||||||
// The ledger lives in the registry ('combatDone'). A save predating
|
// The ledger lives in the registry ('combatDone'). A save predating
|
||||||
// it has no field; the restore stages an empty set (old saves
|
// it has no field; the restore stages an empty set (old saves
|
||||||
// load — the step simply hasn't earned its witness yet).
|
// load — the step simply hasn't earned its witness yet). PLUS the
|
||||||
combat: { done: Array.from(scene.registry.get('combatDone') ?? []) },
|
// 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'
|
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
|
||||||
// requirement's input — the hold is cargo, this is the record).
|
// requirement's input — the hold is cargo, this is the record).
|
||||||
// A save predating it has no field; the restore stages 0.
|
// 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
|
// 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
|
// field → an empty set — old saves load; a fresh run's scenes arm on
|
||||||
// their quest gates in data/vignettes.json).
|
// 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('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('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('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('vignettesPlayed', null); // the scenes re-arm on the new run
|
||||||
registry.set(PENDING_RESTORE_KEY, null);
|
registry.set(PENDING_RESTORE_KEY, null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
|
||||||
import { ActionBar } from '../ui/ActionBar.js';
|
import { ActionBar } from '../ui/ActionBar.js';
|
||||||
import { CombatBar } from '../ui/CombatBar.js';
|
import { CombatBar } from '../ui/CombatBar.js';
|
||||||
import { CombatSystem } from '../combat/CombatSystem.js';
|
import { CombatSystem } from '../combat/CombatSystem.js';
|
||||||
|
import { XpManager } from '../combat/XpManager.js';
|
||||||
import { CombatHud } from '../combat/CombatHud.js';
|
import { CombatHud } from '../combat/CombatHud.js';
|
||||||
import { MineralHud } from '../ui/MineralHud.js';
|
import { MineralHud } from '../ui/MineralHud.js';
|
||||||
import { CreditsHud } from '../ui/CreditsHud.js';
|
import { CreditsHud } from '../ui/CreditsHud.js';
|
||||||
|
|
@ -977,13 +978,23 @@ export class GameScene extends Phaser.Scene {
|
||||||
onCast: (i) => this.combat?.cast(i),
|
onCast: (i) => this.combat?.cast(i),
|
||||||
});
|
});
|
||||||
this.combatBar.setVisible(false);
|
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, {
|
this.combat = new CombatSystem(this, {
|
||||||
ship: this.ship,
|
ship: this.ship,
|
||||||
|
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
|
||||||
onActiveChange: (active) => this._setCombatBar(active),
|
onActiveChange: (active) => this._setCombatBar(active),
|
||||||
// The readability layer's bookkeeping (js/combat/CombatHud.js —
|
// The readability layer's bookkeeping (js/combat/CombatHud.js —
|
||||||
// the same shared layer the range runs): a kill lands in the
|
// the same shared layer the range runs): a kill lands in the
|
||||||
// feed, and a hit flashes the bar's gauge that took it.
|
// feed, and a hit flashes the bar's gauge that took it.
|
||||||
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
|
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) => {
|
onPlayerDamage: (n, a) => {
|
||||||
if (!this.combatHud || !a) return;
|
if (!this.combatHud || !a) return;
|
||||||
if (a.shieldHit > 0) this.combatHud.flash('shield');
|
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),
|
damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
|
||||||
cast: (i) => this.combat?.cast(i),
|
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
|
// WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a
|
||||||
// script (a combat.waves.scripts NAME or a raw wave list), watch
|
// script (a combat.waves.scripts NAME or a raw wave list), watch
|
||||||
// it, cancel it. The quest's own hook is live (the 'Pirate
|
// it, cancel it. The quest's own hook is live (the 'Pirate
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Phaser from '../vendor/phaser.js';
|
import Phaser from '../vendor/phaser.js';
|
||||||
import { config } from '../config/Config.js';
|
import { config } from '../config/Config.js';
|
||||||
|
import { XpManager } from '../combat/XpManager.js';
|
||||||
import { toColor, toCss } from '../utils/Color.js';
|
import { toColor, toCss } from '../utils/Color.js';
|
||||||
import { fontStack } from '../utils/Theme.js';
|
import { fontStack } from '../utils/Theme.js';
|
||||||
import { canvasTexture } from '../utils/Textures.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);
|
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
|
* The soft radial dot (white, 64px) the orb shading layers use — a
|
||||||
* white-tinted copy reads as highlight, a black tint as shadow. Cached
|
* 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):
|
* 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)
|
* (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-
|
* left — the hull ORB (red) with the SHIELD orb (cyan, semi-
|
||||||
* transparent) concentric over it: the shield sits IN FRONT of
|
* transparent) concentric over it: the shield sits IN FRONT of
|
||||||
* the hull, so the red reads through the cyan.
|
* 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
|
* middle — the eight SKILL seats, wearing keys 1-8. The skills land
|
||||||
* later — for now they are empty seats (a press lands on
|
* later — for now they are empty seats (a press lands on
|
||||||
* o.onCast(index); the system's cast() is the no-op stub).
|
* 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 sR = R * shScale;
|
||||||
const SW = n * S + (n - 1) * G;
|
const SW = n * S + (n - 1) * G;
|
||||||
const x0 = (W - totalW) / 2;
|
const x0 = (W - totalW) / 2;
|
||||||
const stripTop = H - mB - stripH;
|
// The XP row's band (the bar grows DOWNWARD from the orbs' row —
|
||||||
const stripBottom = H - mB;
|
// 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 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 hull = { cx: x0 + edgePad + R, cy: cY, r: R };
|
||||||
const shield = { cx: hull.cx + shOX * R, cy: cY + shOY * R, r: sR };
|
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 eCx = firstSlotL + SW + orbGap + R;
|
||||||
const x1 = eCx + R + edgePad;
|
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();
|
const panel = scene.add.graphics();
|
||||||
panel.fillStyle(bg, bgAlpha);
|
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.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.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);
|
panel.setScrollFactor(0).setDepth(DEPTH);
|
||||||
this.add(panel);
|
this.add(panel);
|
||||||
|
|
||||||
|
|
@ -239,8 +272,95 @@ export class CombatBar extends Phaser.GameObjects.Container {
|
||||||
// ---- the energy orb (right end) -----------------------------------
|
// ---- the energy orb (right end) -----------------------------------
|
||||||
this.orbEnergy = mkOrb(eCx, cY, R, energyC, 0.95);
|
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 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 topY = Math.min(hull.cy - hull.r, shield.cy - shield.r);
|
||||||
const botY = Math.max(hull.cy + hull.r, shield.cy + shield.r);
|
const botY = Math.max(hull.cy + hull.r, shield.cy + shield.r);
|
||||||
this.rect = {
|
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.orbHull, frac(s.hull, s.maxHull));
|
||||||
this._setOrb(this.orbShield, frac(s.shields, s.maxShields));
|
this._setOrb(this.orbShield, frac(s.shields, s.maxShields));
|
||||||
this._setOrb(this.orbEnergy, frac(s.energy, s.maxEnergy));
|
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) {
|
_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. */
|
/** Tear down (scene destroy) — the children go with the scene anyway. */
|
||||||
destroy() {
|
destroy() {
|
||||||
this.onCast = null;
|
this.onCast = null;
|
||||||
|
this.xpLabel = null;
|
||||||
|
this.xpLabelChip = null;
|
||||||
|
this.xpNumbers = null;
|
||||||
|
this.xpNumbersChip = null;
|
||||||
|
this.xpTrack = null;
|
||||||
|
this.xpBar = null;
|
||||||
super.destroy();
|
super.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue