Spaces:
Running
Running
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 +1 -1
- web/comboBattler.js +18 -12
- web/enemiesSandbox.js +1 -1
- web/engine.js +1 -1
- web/levelUpCards.js +57 -0
- web/progression.js +2 -1
- web/tiny.js +42 -4
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 =
|
| 6041 |
-
var SPAWN_INTERVAL = 0.
|
| 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
|
| 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 *
|
| 6583 |
-
p.maxHp = Math.round(p.maxHp *
|
| 6584 |
-
p.weapon.min = Math.round(p.weapon.min *
|
| 6585 |
-
p.weapon.max = Math.max(p.weapon.min + 1, Math.round(p.weapon.max *
|
| 6586 |
-
p.
|
|
|
|
| 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 *
|
| 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,
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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)
|