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

Phase 1: leveling — award XP on enemy defeat + character-sheet progression

Browse files

Wire comboBattler's onEnemyDefeated to award XP to the active hero (enemyXpValue),
persist via patchPersona, increment kills, log kill/level_up events, and pop a
level-up banner. Track the active hero (setActiveHeroId) on spawn. Add a level
chip + XP bar + kills/points block to the character sheet. Rebuild comboBattler bundle.

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

Files changed (2) hide show
  1. web/comboBattler.js +7 -2
  2. web/tiny.js +52 -4
web/comboBattler.js CHANGED
@@ -6387,8 +6387,13 @@ function mountComboBattler(pixi, host, opts = {}) {
6387
  if (!p) return;
6388
  for (const a of ea()) {
6389
  if (!a.alive) {
6390
- if (!deadAt.has(a.id)) deadAt.set(a.id, battle.t);
6391
- else if (battle.t - deadAt.get(a.id) > DEATH_LINGER) {
 
 
 
 
 
6392
  R.removeActor(a.id);
6393
  removeActor(battle, a.id);
6394
  deadAt.delete(a.id);
 
6387
  if (!p) return;
6388
  for (const a of ea()) {
6389
  if (!a.alive) {
6390
+ if (!deadAt.has(a.id)) {
6391
+ deadAt.set(a.id, battle.t);
6392
+ try {
6393
+ opts.onEnemyDefeated?.({ id: a.id, name: a.name, profession: a.profession, tier: a.tier, x: a.x, y: a.y, t: battle.t });
6394
+ } catch {
6395
+ }
6396
+ } else if (battle.t - deadAt.get(a.id) > DEATH_LINGER) {
6397
  R.removeActor(a.id);
6398
  removeActor(battle, a.id);
6399
  deadAt.delete(a.id);
web/tiny.js CHANGED
@@ -15,7 +15,8 @@ import { mountMapSandbox } from '/web/mapSandbox.js'
15
  import { mountComboBattler } from '/web/comboBattler.js'
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
- import { listPersonas, onRosterChange, getPortrait } from '/web/personaStore.js'
 
19
  import { mountDiaryPanel } from '/web/diaryPanel.js'
20
  import { mountSettingsPanel } from '/web/settingsPanel.js'
21
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -339,7 +340,10 @@ whenEl('battle-stage', async (el) => {
339
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
340
  // Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
341
  // picker reappears whenever there's no live hero (initial, and after the current one dies).
342
- comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
 
 
 
343
  await comboCtrl.ready
344
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
345
  const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
@@ -349,11 +353,39 @@ whenEl('battle-stage', async (el) => {
349
  const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
350
  const spawnWithFly = (p) => {
351
  picker?.remove(); picker = null
352
- currentHero = p
 
 
353
  const s = comboCtrl.getSpawnWorld()
354
- comboCtrl.selectHero(buildPlayer(chars, p))
355
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
356
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  // Picking a hero opens its detail page (portrait/about/quote + Select); Select confirms → spawnWithFly.
358
  const onPick = (p) => openHeroDetail(el, p, chars, () => spawnWithFly(p))
359
  const buildPicker = () => {
@@ -383,6 +415,22 @@ whenEl('battle-stage', async (el) => {
383
  const hero = comboCtrl.getHero?.()
384
  const name = document.createElement('div'); name.textContent = currentHero.name || hero?.name || 'Hero'; name.style.cssText = 'font:700 18px var(--tac-font,system-ui);text-align:center'; sheet.append(name)
385
  const cls = document.createElement('div'); cls.textContent = [currentHero.unitClass, hero?.profession].filter(Boolean).join(' · '); cls.style.cssText = 'text-align:center;color:#9aa4b2;letter-spacing:.06em;text-transform:uppercase;font-size:11px;margin-bottom:14px'; sheet.append(cls)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  if (hero) {
387
  const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
388
  const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'
 
15
  import { mountComboBattler } from '/web/comboBattler.js'
16
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
17
  import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
18
+ import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
19
+ import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent } from '/web/progression.js'
20
  import { mountDiaryPanel } from '/web/diaryPanel.js'
21
  import { mountSettingsPanel } from '/web/settingsPanel.js'
22
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
340
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
341
  // Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
342
  // picker reappears whenever there's no live hero (initial, and after the current one dies).
343
+ // Award XP to the active hero when it defeats a foe (the loop fires this once per kill).
344
+ // Declared as a closure so it always reads the *current* hero + freshest stored progression.
345
+ const onEnemyDefeated = (enemy) => { try { awardKill(enemy) } catch { /* never break combat */ } }
346
+ comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters, onEnemyDefeated })
347
  await comboCtrl.ready
348
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
349
  const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
 
353
  const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
354
  const spawnWithFly = (p) => {
355
  picker?.remove(); picker = null
356
+ // Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
357
+ currentHero = (p && p.id && getPersona(p.id)) || p
358
+ setActiveHeroId(currentHero?.id || null)
359
  const s = comboCtrl.getSpawnWorld()
360
+ comboCtrl.selectHero(buildPlayer(chars, currentHero))
361
  comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
362
  }
363
+
364
+ // ── Leveling: a foe defeat grants XP to the active hero, persisted to the roster. ──
365
+ const awardKill = (enemy) => {
366
+ const id = currentHero?.id; if (!id) return
367
+ const cur = getPersona(id); if (!cur) return
368
+ const gained = enemyXpValue(enemy)
369
+ const res = applyXp(cur.progression, gained)
370
+ const prog = res.progression
371
+ prog.kills = (cur.progression.kills || 0) + 1
372
+ let events = appendEvent(cur.events, 'kill', { enemy: enemy?.name || 'a foe', xp: gained, ts: Date.now() })
373
+ if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
374
+ patchPersona(id, { progression: prog, events })
375
+ currentHero = getPersona(id)
376
+ if (res.leveledUp) showLevelUp(prog)
377
+ if (sheetOpen) renderSheet()
378
+ }
379
+ // A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
380
+ let levelToast = null
381
+ const showLevelUp = (prog) => {
382
+ levelToast?.remove()
383
+ const t = document.createElement('div')
384
+ t.textContent = `⚔️ ${currentHero?.name || 'Hero'} reached level ${prog.level}!`
385
+ t.style.cssText = 'position:absolute;left:50%;top:18%;transform:translateX(-50%);z-index:9;padding:12px 18px;border-radius:12px;border:1px solid #c9a227;background:rgba(20,24,33,.94);color:#ffe082;font:700 15px var(--tac-font,system-ui);box-shadow:0 6px 24px rgba(0,0,0,.5);pointer-events:none;text-align:center;max-width:86vw'
386
+ el.append(t); levelToast = t
387
+ setTimeout(() => { t.style.transition = 'opacity .5s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 520) }, 2200)
388
+ }
389
  // Picking a hero opens its detail page (portrait/about/quote + Select); Select confirms → spawnWithFly.
390
  const onPick = (p) => openHeroDetail(el, p, chars, () => spawnWithFly(p))
391
  const buildPicker = () => {
 
415
  const hero = comboCtrl.getHero?.()
416
  const name = document.createElement('div'); name.textContent = currentHero.name || hero?.name || 'Hero'; name.style.cssText = 'font:700 18px var(--tac-font,system-ui);text-align:center'; sheet.append(name)
417
  const cls = document.createElement('div'); cls.textContent = [currentHero.unitClass, hero?.profession].filter(Boolean).join(' · '); cls.style.cssText = 'text-align:center;color:#9aa4b2;letter-spacing:.06em;text-transform:uppercase;font-size:11px;margin-bottom:14px'; sheet.append(cls)
418
+ // ── Progression: level chip + XP bar + kills/points ──
419
+ {
420
+ const prog = currentHero.progression || { level: 1, xp: 0, kills: 0, attributePoints: 0 }
421
+ const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:8px;margin:2px 0 10px'
422
+ const lv = document.createElement('span'); lv.textContent = 'LV ' + prog.level; lv.style.cssText = 'font:700 12px var(--tac-font,system-ui);color:#ffe082;background:#2a2410;border:1px solid #c9a227;border-radius:6px;padding:2px 8px;flex:none'
423
+ const barWrap = document.createElement('div'); barWrap.style.cssText = 'flex:1'
424
+ const xpLab = document.createElement('div'); xpLab.style.cssText = 'font-size:10px;color:#9aa4b2;margin-bottom:2px'; xpLab.textContent = `XP ${prog.xp} / ${xpToNext(prog.level)}`
425
+ const xpTrack = document.createElement('div'); xpTrack.style.cssText = 'height:6px;border-radius:5px;background:#222a35;overflow:hidden'
426
+ const xpFill = document.createElement('div'); xpFill.style.cssText = `height:100%;background:#c9a227;width:${Math.round(levelProgress(prog) * 100)}%`
427
+ xpTrack.append(xpFill); barWrap.append(xpLab, xpTrack); row.append(lv, barWrap); sheet.append(row)
428
+ const meta = document.createElement('div'); meta.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#9aa4b2;margin-bottom:10px'
429
+ const kills = document.createElement('span'); kills.textContent = `⚔ ${prog.kills} kills`
430
+ meta.append(kills)
431
+ if (prog.attributePoints > 0) { const pts = document.createElement('span'); pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''}`; pts.style.color = '#ffe082'; meta.append(pts) }
432
+ sheet.append(meta)
433
+ }
434
  if (hero) {
435
  const wrap = document.createElement('div'); wrap.style.cssText = 'margin:6px 0 12px'
436
  const lab = document.createElement('div'); lab.style.cssText = 'font-size:11px;color:#9aa4b2;margin-bottom:3px'