Spaces:
Running
Running
Phase 1: leveling — award XP on enemy defeat + character-sheet progression
Browse filesWire 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>
- web/comboBattler.js +7 -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))
|
| 6391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 353 |
const s = comboCtrl.getSpawnWorld()
|
| 354 |
-
comboCtrl.selectHero(buildPlayer(chars,
|
| 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'
|