Halve XP curve base from 100 to 50 for faster early leveling
- Level 1 now costs 5 kills of the base craft instead of 10 - Total XP to reach level 80 drops from ~17.4M to ~8.7M - Update XpManager default, progression data, tests, and README to match
This commit is contained in:
parent
8ceb21808f
commit
0cf859363c
|
|
@ -93,8 +93,8 @@ respawns, the bar — lives in **`data/combat.json`**:
|
|||
(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
|
||||
`base × n^power`, shipped 50 × n², capped at level 80; five base kills
|
||||
buy level 1, the whole run to the cap is 8.7M 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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
},
|
||||
|
||||
"enemy": {
|
||||
"_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). 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.",
|
||||
"_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 five kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
|
||||
"texture": "assets/images/ships-enemy.png",
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_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).",
|
||||
"_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 5 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 50 XP (5 × 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, 5n). 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,
|
||||
"base": 50,
|
||||
"power": 2,
|
||||
"reward": {
|
||||
"hullPerLevel": 3,
|
||||
|
|
|
|||
|
|
@ -55,19 +55,19 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
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);
|
||||
// The curve (data/progression.json — base × n^power, shipped: 50 × n²):
|
||||
check('curve: level 1 = 50 XP (the data, not a private copy)', m.xpForLevel(1) === 50);
|
||||
check('curve: level 1 = five base kills (the barometer)', m.xpForLevel(1) === 5 * baseXp);
|
||||
check('curve: level 2 = 200 XP', m.xpForLevel(2) === 200);
|
||||
check('curve: level 3 = 450 XP', m.xpForLevel(3) === 450);
|
||||
check('curve: level 80 = 320,000 XP', m.xpForLevel(80) === 320000);
|
||||
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);
|
||||
m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 50);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the grants
|
||||
|
|
@ -76,12 +76,12 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
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.
|
||||
// The barometer, as a player would see it: five 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);
|
||||
for (let i = 0; i < 5; i++) m.grant(baseXp, 'kill');
|
||||
check('grant: five base kills → level 1 (xp 0, the bank is spent)',
|
||||
m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 200);
|
||||
sub();
|
||||
m.grant(999);
|
||||
check('grant: the unsubscribe stops the listener (no more levelup events)',
|
||||
|
|
@ -89,15 +89,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
|
||||
// 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.
|
||||
// 50+200+450+800+1250+1800+2450 = 7,000 → level 7, 3,000 banked toward 8.
|
||||
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]));
|
||||
const r = m2.grant(10000, 'dev'); // → level 7
|
||||
check("grant: a big grant crosses to level 7 (the curve's math)",
|
||||
r.level === 7 && r.levelsGained === 7 && r.leveledUp === true && m2.xp === 3000);
|
||||
check('grant: one levelup event PER level crossed (1,2,3,4,5,6,7)',
|
||||
JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6, 7]));
|
||||
|
||||
// The source tag rides the events (the ledger's audit trail).
|
||||
const m3 = new XpManager();
|
||||
|
|
@ -141,9 +141,9 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
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
|
||||
// 8,694,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 totalToCap = (50 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 50
|
||||
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);
|
||||
|
|
@ -177,14 +177,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
|
||||
{
|
||||
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);
|
||||
m.grant(550, 'kill'); // 550 = 50 + 200 + 300 → level 2, 300 banked
|
||||
check('state: level 2 with 300 banked toward level 3', m.level === 2 && m.xp === 300);
|
||||
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);
|
||||
json.level === 2 && json.xp === 300 && Object.keys(json).length === 2);
|
||||
|
||||
const m2 = XpManager.fromJSON(json);
|
||||
check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 50);
|
||||
check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 300);
|
||||
|
||||
// A restore clamps junk (a hand-edited record can't break the run).
|
||||
const m3 = XpManager.fromJSON({ level: 999, xp: -50 });
|
||||
|
|
@ -199,14 +199,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
|
||||
// stats(): the bar's snapshot (progress 0..1, the next level's cost).
|
||||
const m5 = new XpManager();
|
||||
m5.grant(50, 'kill');
|
||||
m5.grant(25, '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);
|
||||
check('stats: level 0, 25/50 toward level 1',
|
||||
s.level === 0 && s.xp === 25 && s.xpForNext === 50 && 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));
|
||||
check('stats: after crossing, 35/200 toward level 2',
|
||||
s2.level === 1 && s2.xp === 35 && s2.xpForNext === 200 && approx(s2.progress, 0.175));
|
||||
}
|
||||
|
||||
// --------------------------------------------------- the system's wiring
|
||||
|
|
@ -312,15 +312,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
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++) {
|
||||
// Four more base kills → the level lands: the armor bump + the beat.
|
||||
for (let i = 0; i < 4; 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)',
|
||||
check('system: five 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);
|
||||
|
|
@ -332,7 +332,7 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|||
// 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)",
|
||||
check("system: the bomber's kill banks 25 (toward level 2's 200)",
|
||||
combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1);
|
||||
|
||||
// A RESTORED ledger: the level's armor applies on build — with no
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
*
|
||||
* 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,
|
||||
* with the shipped values (maxLevel 80, base 50, power 2) that makes
|
||||
* level 1 cost 50 XP — five 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
|
||||
|
|
@ -45,7 +45,7 @@ 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.base = Math.max(0, Number(config.get('progression.base', 50)) || 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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue