polats Claude Opus 4.8 (1M context) commited on
Commit
f3c5314
·
1 Parent(s): ea06170

Game: roguelike level-up cards, XP HUD, pause-on-sidebar, advancing waves

Browse files

- Level-up now opens a 1-of-3 perk draft (levelUpCards.js: Vigor/Toughness/Might/
Berserker/Swiftness/Second Wind) that pauses the game; picking applies a persisted stat
(progression.spent → statBonuses incl. speedMul) + an immediate live buffHero effect.
Replaces the attribute-point grant (ATTR_PER_LEVEL=0).
- XP/level chip on the HUD (top-left), updated on spawn + each kill.
- Character sidebar open → comboCtrl.setPaused(true) freezes the game (verified: 0 px
change over 2.2s).
- Enemies advance toward the hero (aggroRadius null) in bigger threat-scaled waves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/classesSandbox.js CHANGED
@@ -999,7 +999,7 @@ function makeActor(unit, team, id, slot) {
999
  rank: unit.rank ?? 12,
1000
  armor: tpl.armor ?? 0,
1001
  weapon,
1002
- moveSpeed: tpl.moveSpeed,
1003
  preferredRange,
1004
  radius: radiusOf(unit, tpl),
1005
  maxEnergy: tpl.maxEnergy,
 
999
  rank: unit.rank ?? 12,
1000
  armor: tpl.armor ?? 0,
1001
  weapon,
1002
+ moveSpeed: Math.round(tpl.moveSpeed * (unit.speedMul ?? 1)),
1003
  preferredRange,
1004
  radius: radiusOf(unit, tpl),
1005
  maxEnergy: tpl.maxEnergy,
web/comboBattler.js CHANGED
@@ -5083,7 +5083,7 @@ function makeActor(unit, team, id, slot) {
5083
  rank: unit.rank ?? 12,
5084
  armor: tpl.armor ?? 0,
5085
  weapon,
5086
- moveSpeed: tpl.moveSpeed,
5087
  preferredRange,
5088
  radius: radiusOf(unit, tpl),
5089
  maxEnergy: tpl.maxEnergy,
@@ -6037,8 +6037,8 @@ var SPAWN_TILES = 22;
6037
  var SPAWN_JITTER = 6;
6038
  var DESPAWN_TILES = 52;
6039
  var TARGET_FOES = 8;
6040
- var MAX_FOES = 16;
6041
- var SPAWN_INTERVAL = 0.4;
6042
  var DEATH_LINGER = 1.6;
6043
  async function contentHeight(url) {
6044
  try {
@@ -6088,6 +6088,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6088
  const emoPrev = /* @__PURE__ */ new Map();
6089
  let idleEmoAcc = 0;
6090
  let runState = { time: 0, kills: 0, diff: 1 };
 
6091
  let hitstopT = 0;
6092
  const shake = { t: 0, mag: 0 };
6093
  const addShake = (dur, mag) => {
@@ -6411,6 +6412,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6411
  };
6412
  function tick(ticker) {
6413
  if (!battle || !R) return;
 
6414
  const dtMS = ticker.deltaMS, dt = dtMS / 1e3;
6415
  const p = pa();
6416
  if (req.dodge) {
@@ -6575,17 +6577,21 @@ function mountComboBattler(pixi, host, opts = {}) {
6575
  const p = pa();
6576
  if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
6577
  }
6578
- function levelUpHero() {
6579
  const p = pa();
6580
  if (!p) return;
6581
- p.rank = Math.min(20, (p.rank || 12) + 1);
6582
- p.baseMaxHp = Math.round(p.baseMaxHp * 1.06);
6583
- p.maxHp = Math.round(p.maxHp * 1.06);
6584
- p.weapon.min = Math.round(p.weapon.min * 1.05);
6585
- p.weapon.max = Math.max(p.weapon.min + 1, Math.round(p.weapon.max * 1.05));
6586
- p.hp = p.maxHp;
 
6587
  emit();
6588
  }
 
 
 
6589
  function drawRings() {
6590
  const p = pa();
6591
  rings.clear();
@@ -6839,7 +6845,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6839
  if (spawnAcc.t < SPAWN_INTERVAL) return;
6840
  spawnAcc.t = 0;
6841
  const diff = runState.diff;
6842
- const target = Math.min(MAX_FOES, Math.round(TARGET_FOES + diff * 1.5));
6843
  const need = target - ea().filter((a) => a.alive).length;
6844
  for (let i = 0; i < need; i++) {
6845
  const ang = Math.random() * Math.PI * 2;
@@ -6931,7 +6937,7 @@ function mountComboBattler(pixi, host, opts = {}) {
6931
  const p = pa();
6932
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6933
  }
6934
- const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, levelUpHero, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6935
  if (typeof window !== "undefined") {
6936
  window.__comboSnap = () => ctrl.getSnapshot();
6937
  window.__combo = ctrl;
 
5083
  rank: unit.rank ?? 12,
5084
  armor: tpl.armor ?? 0,
5085
  weapon,
5086
+ moveSpeed: Math.round(tpl.moveSpeed * (unit.speedMul ?? 1)),
5087
  preferredRange,
5088
  radius: radiusOf(unit, tpl),
5089
  maxEnergy: tpl.maxEnergy,
 
6037
  var SPAWN_JITTER = 6;
6038
  var DESPAWN_TILES = 52;
6039
  var TARGET_FOES = 8;
6040
+ var MAX_FOES = 26;
6041
+ var SPAWN_INTERVAL = 0.3;
6042
  var DEATH_LINGER = 1.6;
6043
  async function contentHeight(url) {
6044
  try {
 
6088
  const emoPrev = /* @__PURE__ */ new Map();
6089
  let idleEmoAcc = 0;
6090
  let runState = { time: 0, kills: 0, diff: 1 };
6091
+ let paused = false;
6092
  let hitstopT = 0;
6093
  const shake = { t: 0, mag: 0 };
6094
  const addShake = (dur, mag) => {
 
6412
  };
6413
  function tick(ticker) {
6414
  if (!battle || !R) return;
6415
+ if (paused) return;
6416
  const dtMS = ticker.deltaMS, dt = dtMS / 1e3;
6417
  const p = pa();
6418
  if (req.dodge) {
 
6577
  const p = pa();
6578
  if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
6579
  }
6580
+ function buffHero({ hpMul = 1, dmgMul = 1, speedMul = 1, rankUp = true, heal = false } = {}) {
6581
  const p = pa();
6582
  if (!p) return;
6583
+ if (rankUp) p.rank = Math.min(20, (p.rank || 12) + 1);
6584
+ p.baseMaxHp = Math.round(p.baseMaxHp * hpMul);
6585
+ p.maxHp = Math.round(p.maxHp * hpMul);
6586
+ p.weapon.min = Math.round(p.weapon.min * dmgMul);
6587
+ p.weapon.max = Math.max(p.weapon.min + 1, Math.round(p.weapon.max * dmgMul));
6588
+ p.moveSpeed = Math.round(p.moveSpeed * speedMul);
6589
+ if (heal) p.hp = p.maxHp;
6590
  emit();
6591
  }
6592
+ function setPaused(v) {
6593
+ paused = !!v;
6594
+ }
6595
  function drawRings() {
6596
  const p = pa();
6597
  rings.clear();
 
6845
  if (spawnAcc.t < SPAWN_INTERVAL) return;
6846
  spawnAcc.t = 0;
6847
  const diff = runState.diff;
6848
+ const target = Math.min(MAX_FOES, Math.round(TARGET_FOES + diff * 4));
6849
  const need = target - ea().filter((a) => a.alive).length;
6850
  for (let i = 0; i < need; i++) {
6851
  const ang = Math.random() * Math.PI * 2;
 
6937
  const p = pa();
6938
  return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
6939
  }
6940
+ const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, buffHero, setPaused, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
6941
  if (typeof window !== "undefined") {
6942
  window.__comboSnap = () => ctrl.getSnapshot();
6943
  window.__combo = ctrl;
web/enemiesSandbox.js CHANGED
@@ -832,7 +832,7 @@ function makeActor(unit, team, id, slot) {
832
  rank: unit.rank ?? 12,
833
  armor: tpl.armor ?? 0,
834
  weapon,
835
- moveSpeed: tpl.moveSpeed,
836
  preferredRange,
837
  radius: radiusOf(unit, tpl),
838
  maxEnergy: tpl.maxEnergy,
 
832
  rank: unit.rank ?? 12,
833
  armor: tpl.armor ?? 0,
834
  weapon,
835
+ moveSpeed: Math.round(tpl.moveSpeed * (unit.speedMul ?? 1)),
836
  preferredRange,
837
  radius: radiusOf(unit, tpl),
838
  maxEnergy: tpl.maxEnergy,
web/engine.js CHANGED
@@ -806,7 +806,7 @@ function makeActor(unit, team, id, slot) {
806
  rank: unit.rank ?? 12,
807
  armor: tpl.armor ?? 0,
808
  weapon,
809
- moveSpeed: tpl.moveSpeed,
810
  preferredRange,
811
  radius: radiusOf(unit, tpl),
812
  maxEnergy: tpl.maxEnergy,
 
806
  rank: unit.rank ?? 12,
807
  armor: tpl.armor ?? 0,
808
  weapon,
809
+ moveSpeed: Math.round(tpl.moveSpeed * (unit.speedMul ?? 1)),
810
  preferredRange,
811
  radius: radiusOf(unit, tpl),
812
  maxEnergy: tpl.maxEnergy,
web/levelUpCards.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Roguelike level-up draft — on level-up the game pauses and the player picks 1 of 3 perk cards.
2
+ // Each perk bumps a persisted stat (progression.spent, read by statBonuses at spawn) AND applies
3
+ // an immediate live effect to the on-field hero (comboCtrl.buffHero). See tiny.js awardKill.
4
+
5
+ // Perk pool. `spend` mutates the spent counters (authoritative, applied next spawn); `live` is the
6
+ // immediate effect on the current hero.
7
+ const POOL = [
8
+ { key: 'vigor', icon: '❤', title: 'Vigor', desc: '+10% Max HP & heal', spend: (s) => ({ ...s, hp: (s.hp || 0) + 2 }), live: { hpMul: 1.10, heal: true } },
9
+ { key: 'toughness', icon: '🛡', title: 'Toughness', desc: '+15% Max HP', spend: (s) => ({ ...s, hp: (s.hp || 0) + 3 }), live: { hpMul: 1.15, heal: true } },
10
+ { key: 'might', icon: '⚔', title: 'Might', desc: '+10% Damage', spend: (s) => ({ ...s, dmg: (s.dmg || 0) + 2 }), live: { dmgMul: 1.10 } },
11
+ { key: 'berserker', icon: '🔥', title: 'Berserker', desc: '+15% Damage', spend: (s) => ({ ...s, dmg: (s.dmg || 0) + 3 }), live: { dmgMul: 1.15 } },
12
+ { key: 'swift', icon: '💨', title: 'Swiftness', desc: '+12% Move speed', spend: (s) => ({ ...s, spd: (s.spd || 0) + 2 }), live: { speedMul: 1.12 } },
13
+ { key: 'second-wind', icon: '✨', title: 'Second Wind', desc: 'Full heal +5% Max HP', spend: (s) => ({ ...s, hp: (s.hp || 0) + 1 }), live: { hpMul: 1.05, heal: true } },
14
+ ]
15
+
16
+ // Pick `n` distinct perks. Seeded-ish via the level so multi-level-ups vary without Math.random reliance.
17
+ export function drawPerks(n = 3, seed = 0) {
18
+ const pool = POOL.slice()
19
+ // Fisher–Yates with a tiny LCG so draws differ between levels.
20
+ let x = (seed * 2654435761 + 12345) >>> 0
21
+ const rnd = () => (x = (1103515245 * x + 12345) >>> 0) / 4294967296
22
+ for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1));[pool[i], pool[j]] = [pool[j], pool[i]] }
23
+ return pool.slice(0, Math.min(n, pool.length))
24
+ }
25
+
26
+ // Apply a chosen perk to a progression object → new progression (updated spent counters).
27
+ export function applyPerk(progression, perk) {
28
+ const p = { level: 1, xp: 0, kills: 0, attributePoints: 0, spent: { hp: 0, dmg: 0, spd: 0 }, ...(progression || {}) }
29
+ p.spent = { hp: 0, dmg: 0, spd: 0, ...(p.spent || {}) }
30
+ p.spent = perk.spend(p.spent)
31
+ return p
32
+ }
33
+
34
+ // Render the 3-card draft over `host`. Calls onPick(perk) when a card is chosen, then closes.
35
+ export function mountLevelUp(host, { level, seed = level }, onPick) {
36
+ const back = document.createElement('div')
37
+ back.style.cssText = 'position:absolute;inset:0;z-index:14;background:rgba(6,8,12,.86);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:16px;gap:14px'
38
+ const title = document.createElement('div'); title.textContent = `⚔ Level ${level} — choose a boon`
39
+ title.style.cssText = 'font:800 22px var(--tac-font,system-ui);color:#ffe082;text-shadow:0 2px 8px #000;text-align:center'
40
+ back.append(title)
41
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center;max-width:560px'
42
+ const close = () => back.remove()
43
+ for (const perk of drawPerks(3, seed)) {
44
+ const card = document.createElement('button'); card.type = 'button'
45
+ card.style.cssText = 'width:150px;min-height:170px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px 12px;border-radius:14px;border:1px solid #2a3340;background:linear-gradient(180deg,#1a2230,#11151c);color:#e8e8e8;cursor:pointer;transition:transform .1s,border-color .1s;user-select:none'
46
+ card.addEventListener('pointerenter', () => { card.style.transform = 'translateY(-4px)'; card.style.borderColor = '#c9a227' })
47
+ card.addEventListener('pointerleave', () => { card.style.transform = 'none'; card.style.borderColor = '#2a3340' })
48
+ const icon = document.createElement('div'); icon.textContent = perk.icon; icon.style.cssText = 'font-size:40px'
49
+ const nm = document.createElement('div'); nm.textContent = perk.title; nm.style.cssText = 'font:700 16px var(--tac-font,system-ui)'
50
+ const ds = document.createElement('div'); ds.textContent = perk.desc; ds.style.cssText = 'font-size:12px;color:#9aa4b2;text-align:center;line-height:1.4'
51
+ card.append(icon, nm, ds)
52
+ card.addEventListener('click', () => { close(); onPick(perk) })
53
+ row.append(card)
54
+ }
55
+ back.append(row); host.append(back)
56
+ return { close }
57
+ }
web/progression.js CHANGED
@@ -6,7 +6,7 @@
6
 
7
  const BASE = 50
8
  const GROWTH = 40
9
- const ATTR_PER_LEVEL = 1
10
 
11
  export function xpToNext(level) {
12
  const l = Math.max(1, level | 0)
@@ -72,6 +72,7 @@ export function statBonuses(progression) {
72
  rank: Math.min(20, 7 + lvl),
73
  hpMul: 1 + (lvl - 1) * 0.07 + (spent.hp || 0) * 0.05,
74
  dmgMul: 1 + (lvl - 1) * 0.05 + (spent.dmg || 0) * 0.05,
 
75
  }
76
  }
77
 
 
6
 
7
  const BASE = 50
8
  const GROWTH = 40
9
+ const ATTR_PER_LEVEL = 0 // level-up reward is now the roguelike perk draft (levelUpCards.js), not points
10
 
11
  export function xpToNext(level) {
12
  const l = Math.max(1, level | 0)
 
72
  rank: Math.min(20, 7 + lvl),
73
  hpMul: 1 + (lvl - 1) * 0.07 + (spent.hp || 0) * 0.05,
74
  dmgMul: 1 + (lvl - 1) * 0.05 + (spent.dmg || 0) * 0.05,
75
+ speedMul: 1 + (spent.spd || 0) * 0.06,
76
  }
77
  }
78
 
web/tiny.js CHANGED
@@ -23,6 +23,7 @@ import { generatePortrait } from '/web/imagen.js'
23
  import { mountCharacterChat } from '/web/characterChat.js'
24
  import { forgeSkillForHero } from '/web/skillForge.js'
25
  import { mountAfterAction } from '/web/afterAction.js'
 
26
  import { mountDiaryPanel } from '/web/diaryPanel.js'
27
  import { mountSettingsPanel } from '/web/settingsPanel.js'
28
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -235,7 +236,7 @@ const buildPlayer = (chars, p) => {
235
  if (equipped.length) unit.skills = equipped
236
  // Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
237
  const sb = statBonuses(p?.progression)
238
- unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul; unit.rangeMul = ROAM_RANGE_MUL
239
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
240
  }
241
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
@@ -352,7 +353,8 @@ whenEl('battle-stage', async (el) => {
352
  const chars = await loadChars()
353
  const buildRoster = (list) => list.map((e) => {
354
  const c = chars[e.slug]; if (!c) return null
355
- return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO, rangeMul: ROAM_RANGE_MUL } }
 
356
  }).filter(Boolean)
357
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
358
  // Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
@@ -375,6 +377,7 @@ whenEl('battle-stage', async (el) => {
375
  setActiveHeroId(currentHero?.id || null)
376
  runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
377
  deathShown = false
 
378
  const s = comboCtrl.getSpawnWorld()
379
  comboCtrl.selectHero(buildPlayer(chars, currentHero))
380
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
@@ -407,8 +410,28 @@ whenEl('battle-stage', async (el) => {
407
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
408
  patchPersona(id, { progression: prog, events })
409
  currentHero = getPersona(id)
410
- if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love'); comboCtrl?.levelUpHero?.() }
411
  if (sheetOpen) renderSheet()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  }
413
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
414
  let levelToast = null
@@ -427,7 +450,7 @@ whenEl('battle-stage', async (el) => {
427
  return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
428
  () => openCreateModal(el, spawnWithFly))
429
  }
430
- const showPicker = () => { if (!picker) { picker = buildPicker(); flyToOverview() } }
431
  // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
432
  // up in the "Choose your hero" bar without waiting for the next time it reopens.
433
  const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
@@ -704,6 +727,7 @@ whenEl('battle-stage', async (el) => {
704
  let sheetBackdrop = null
705
  const setSheet = (open) => {
706
  sheetOpen = open; sheet.style.transform = open ? 'translateX(0)' : 'translateX(100%)'
 
707
  if (open) {
708
  renderSheet()
709
  if (!sheetBackdrop) { sheetBackdrop = document.createElement('div'); sheetBackdrop.style.cssText = 'position:absolute;inset:0;z-index:7;background:transparent'; sheetBackdrop.addEventListener('pointerdown', () => setSheet(false)); el.append(sheetBackdrop) }
@@ -712,6 +736,20 @@ whenEl('battle-stage', async (el) => {
712
  sheetBtn.addEventListener('click', () => setSheet(!sheetOpen))
713
  el.append(sheetBtn, sheet)
714
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  showPicker()
716
  comboCtrl.onChange((s) => { if (s.over) onHeroDown(); if (sheetOpen) updateHp(comboCtrl.getHero?.()) })
717
  onRosterChange(refreshPicker)
 
23
  import { mountCharacterChat } from '/web/characterChat.js'
24
  import { forgeSkillForHero } from '/web/skillForge.js'
25
  import { mountAfterAction } from '/web/afterAction.js'
26
+ import { mountLevelUp, applyPerk } from '/web/levelUpCards.js'
27
  import { mountDiaryPanel } from '/web/diaryPanel.js'
28
  import { mountSettingsPanel } from '/web/settingsPanel.js'
29
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
236
  if (equipped.length) unit.skills = equipped
237
  // Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
238
  const sb = statBonuses(p?.progression)
239
+ unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul; unit.speedMul = sb.speedMul; unit.rangeMul = ROAM_RANGE_MUL
240
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
241
  }
242
  const PICK_CARD_CSS = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
 
353
  const chars = await loadChars()
354
  const buildRoster = (list) => list.map((e) => {
355
  const c = chars[e.slug]; if (!c) return null
356
+ // aggroRadius null = always engage foes advance toward the hero (waves), not idle until near.
357
+ return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: null, rangeMul: ROAM_RANGE_MUL } }
358
  }).filter(Boolean)
359
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
360
  // Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
 
377
  setActiveHeroId(currentHero?.id || null)
378
  runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
379
  deathShown = false
380
+ updateXpHud()
381
  const s = comboCtrl.getSpawnWorld()
382
  comboCtrl.selectHero(buildPlayer(chars, currentHero))
383
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
 
410
  if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
411
  patchPersona(id, { progression: prog, events })
412
  currentHero = getPersona(id)
413
+ updateXpHud()
414
  if (sheetOpen) renderSheet()
415
+ if (res.leveledUp) { comboCtrl?.heroEmote?.('love'); startLevelUpDrafts(res.levelsGained) }
416
+ }
417
+ // Roguelike level-up: pause and present a 1-of-3 perk draft per level gained (sequential).
418
+ const startLevelUpDrafts = (count) => {
419
+ let remaining = count || 1
420
+ const showNext = () => {
421
+ if (remaining <= 0 || !currentHero?.id) { comboCtrl?.setPaused?.(false); return }
422
+ remaining--
423
+ comboCtrl?.setPaused?.(true)
424
+ const lvl = currentHero?.progression?.level || 1
425
+ mountLevelUp(el, { level: lvl, seed: lvl * 7 + remaining }, (perk) => {
426
+ const fresh = getPersona(currentHero.id); if (!fresh) { comboCtrl?.setPaused?.(false); return }
427
+ patchPersona(currentHero.id, { progression: applyPerk(fresh.progression, perk) })
428
+ currentHero = getPersona(currentHero.id)
429
+ comboCtrl?.buffHero?.(perk.live || {})
430
+ updateXpHud(); if (sheetOpen) renderSheet()
431
+ showNext()
432
+ })
433
+ }
434
+ showNext()
435
  }
436
  // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
437
  let levelToast = null
 
450
  return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
451
  () => openCreateModal(el, spawnWithFly))
452
  }
453
+ const showPicker = () => { xpHud.style.display = 'none'; if (!picker) { picker = buildPicker(); flyToOverview() } }
454
  // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
455
  // up in the "Choose your hero" bar without waiting for the next time it reopens.
456
  const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
 
727
  let sheetBackdrop = null
728
  const setSheet = (open) => {
729
  sheetOpen = open; sheet.style.transform = open ? 'translateX(0)' : 'translateX(100%)'
730
+ comboCtrl?.setPaused?.(open) // freeze the game while the character page is open
731
  if (open) {
732
  renderSheet()
733
  if (!sheetBackdrop) { sheetBackdrop = document.createElement('div'); sheetBackdrop.style.cssText = 'position:absolute;inset:0;z-index:7;background:transparent'; sheetBackdrop.addEventListener('pointerdown', () => setSheet(false)); el.append(sheetBackdrop) }
 
736
  sheetBtn.addEventListener('click', () => setSheet(!sheetOpen))
737
  el.append(sheetBtn, sheet)
738
 
739
+ // ── XP / level HUD chip (top-left) — always visible while a hero is in play ──
740
+ const xpHud = document.createElement('div')
741
+ xpHud.style.cssText = 'position:absolute;top:14px;left:14px;z-index:6;pointer-events:none;display:none;align-items:center;gap:8px;background:rgba(20,24,33,.8);border:1px solid #2a3340;border-radius:999px;padding:5px 12px 5px 9px;font:700 12px monospace;color:#e8e8e8'
742
+ const xpLv = document.createElement('span'); xpLv.style.cssText = 'color:#ffe082'
743
+ const xpTrack = document.createElement('div'); xpTrack.style.cssText = 'width:90px;height:6px;border-radius:4px;background:#222a35;overflow:hidden'
744
+ const xpFill = document.createElement('div'); xpFill.style.cssText = 'height:100%;width:0%;background:linear-gradient(90deg,#c9a227,#ffe082);transition:width .2s'
745
+ xpTrack.append(xpFill); xpHud.append(xpLv, xpTrack); el.append(xpHud)
746
+ const updateXpHud = () => {
747
+ const prog = currentHero && currentHero.id ? currentHero.progression : null
748
+ if (!prog) { xpHud.style.display = 'none'; return }
749
+ xpHud.style.display = 'flex'; xpLv.textContent = 'LV ' + prog.level
750
+ xpFill.style.width = Math.round(levelProgress(prog) * 100) + '%'
751
+ }
752
+
753
  showPicker()
754
  comboCtrl.onChange((s) => { if (s.over) onHeroDown(); if (sheetOpen) updateHp(comboCtrl.getHero?.()) })
755
  onRosterChange(refreshPicker)