Spaces:
Running
Running
Fun: leveling now scales power + spend attribute points
Browse filesstatBonuses(level+spent) -> rank/hpMul/dmgMul fed into buildPlayer, so a higher-level
hero is genuinely tougher and hits harder. Level-up triggers an immediate live buff +
full heal. Attribute points are spendable (+5% HP / +5% DMG) from the character sheet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/classesSandbox.js +8 -4
- web/comboBattler.js +20 -5
- web/enemiesSandbox.js +8 -4
- web/engine.js +8 -4
- web/progression.js +24 -0
- web/tiny.js +28 -3
web/classesSandbox.js
CHANGED
|
@@ -976,6 +976,10 @@ function templateFor(unit) {
|
|
| 976 |
}
|
| 977 |
function makeActor(unit, team, id, slot) {
|
| 978 |
const tpl = templateFor(unit);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
const p = FORMATION[slot % FORMATION.length];
|
| 980 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 981 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
@@ -991,15 +995,15 @@ function makeActor(unit, team, id, slot) {
|
|
| 991 |
role: tpl.role,
|
| 992 |
rank: unit.rank ?? 12,
|
| 993 |
armor: tpl.armor ?? 0,
|
| 994 |
-
weapon
|
| 995 |
moveSpeed: tpl.moveSpeed,
|
| 996 |
preferredRange: tpl.preferredRange,
|
| 997 |
radius: radiusOf(unit, tpl),
|
| 998 |
maxEnergy: tpl.maxEnergy,
|
| 999 |
energyRegen: tpl.energyRegen,
|
| 1000 |
-
baseMaxHp:
|
| 1001 |
-
maxHp
|
| 1002 |
-
hp:
|
| 1003 |
energy: tpl.maxEnergy,
|
| 1004 |
adrenaline: 0,
|
| 1005 |
bar,
|
|
|
|
| 976 |
}
|
| 977 |
function makeActor(unit, team, id, slot) {
|
| 978 |
const tpl = templateFor(unit);
|
| 979 |
+
const hpMul = unit.hpMul ?? unit.statMul ?? 1;
|
| 980 |
+
const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
|
| 981 |
+
const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
|
| 982 |
+
const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
|
| 983 |
const p = FORMATION[slot % FORMATION.length];
|
| 984 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 985 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
|
|
| 995 |
role: tpl.role,
|
| 996 |
rank: unit.rank ?? 12,
|
| 997 |
armor: tpl.armor ?? 0,
|
| 998 |
+
weapon,
|
| 999 |
moveSpeed: tpl.moveSpeed,
|
| 1000 |
preferredRange: tpl.preferredRange,
|
| 1001 |
radius: radiusOf(unit, tpl),
|
| 1002 |
maxEnergy: tpl.maxEnergy,
|
| 1003 |
energyRegen: tpl.energyRegen,
|
| 1004 |
+
baseMaxHp: maxHp,
|
| 1005 |
+
maxHp,
|
| 1006 |
+
hp: maxHp,
|
| 1007 |
energy: tpl.maxEnergy,
|
| 1008 |
adrenaline: 0,
|
| 1009 |
bar,
|
web/comboBattler.js
CHANGED
|
@@ -5056,6 +5056,10 @@ function templateFor(unit) {
|
|
| 5056 |
}
|
| 5057 |
function makeActor(unit, team, id, slot) {
|
| 5058 |
const tpl = templateFor(unit);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5059 |
const p = FORMATION[slot % FORMATION.length];
|
| 5060 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 5061 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
@@ -5071,15 +5075,15 @@ function makeActor(unit, team, id, slot) {
|
|
| 5071 |
role: tpl.role,
|
| 5072 |
rank: unit.rank ?? 12,
|
| 5073 |
armor: tpl.armor ?? 0,
|
| 5074 |
-
weapon
|
| 5075 |
moveSpeed: tpl.moveSpeed,
|
| 5076 |
preferredRange: tpl.preferredRange,
|
| 5077 |
radius: radiusOf(unit, tpl),
|
| 5078 |
maxEnergy: tpl.maxEnergy,
|
| 5079 |
energyRegen: tpl.energyRegen,
|
| 5080 |
-
baseMaxHp:
|
| 5081 |
-
maxHp
|
| 5082 |
-
hp:
|
| 5083 |
energy: tpl.maxEnergy,
|
| 5084 |
adrenaline: 0,
|
| 5085 |
bar,
|
|
@@ -6373,6 +6377,17 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6373 |
const p = pa();
|
| 6374 |
if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
|
| 6375 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6376 |
function drawRings() {
|
| 6377 |
const p = pa();
|
| 6378 |
rings.clear();
|
|
@@ -6665,7 +6680,7 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6665 |
const p = pa();
|
| 6666 |
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;
|
| 6667 |
}
|
| 6668 |
-
const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, setHeroSkills, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
|
| 6669 |
if (typeof window !== "undefined") {
|
| 6670 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 6671 |
window.__combo = ctrl;
|
|
|
|
| 5056 |
}
|
| 5057 |
function makeActor(unit, team, id, slot) {
|
| 5058 |
const tpl = templateFor(unit);
|
| 5059 |
+
const hpMul = unit.hpMul ?? unit.statMul ?? 1;
|
| 5060 |
+
const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
|
| 5061 |
+
const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
|
| 5062 |
+
const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
|
| 5063 |
const p = FORMATION[slot % FORMATION.length];
|
| 5064 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 5065 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
|
|
| 5075 |
role: tpl.role,
|
| 5076 |
rank: unit.rank ?? 12,
|
| 5077 |
armor: tpl.armor ?? 0,
|
| 5078 |
+
weapon,
|
| 5079 |
moveSpeed: tpl.moveSpeed,
|
| 5080 |
preferredRange: tpl.preferredRange,
|
| 5081 |
radius: radiusOf(unit, tpl),
|
| 5082 |
maxEnergy: tpl.maxEnergy,
|
| 5083 |
energyRegen: tpl.energyRegen,
|
| 5084 |
+
baseMaxHp: maxHp,
|
| 5085 |
+
maxHp,
|
| 5086 |
+
hp: maxHp,
|
| 5087 |
energy: tpl.maxEnergy,
|
| 5088 |
adrenaline: 0,
|
| 5089 |
bar,
|
|
|
|
| 6377 |
const p = pa();
|
| 6378 |
if (emoteLayer && p && battle) emoteLayer.show(p.id, key, battle.t, 1.6);
|
| 6379 |
}
|
| 6380 |
+
function levelUpHero() {
|
| 6381 |
+
const p = pa();
|
| 6382 |
+
if (!p) return;
|
| 6383 |
+
p.rank = Math.min(20, (p.rank || 12) + 1);
|
| 6384 |
+
p.baseMaxHp = Math.round(p.baseMaxHp * 1.06);
|
| 6385 |
+
p.maxHp = Math.round(p.maxHp * 1.06);
|
| 6386 |
+
p.weapon.min = Math.round(p.weapon.min * 1.05);
|
| 6387 |
+
p.weapon.max = Math.max(p.weapon.min + 1, Math.round(p.weapon.max * 1.05));
|
| 6388 |
+
p.hp = p.maxHp;
|
| 6389 |
+
emit();
|
| 6390 |
+
}
|
| 6391 |
function drawRings() {
|
| 6392 |
const p = pa();
|
| 6393 |
rings.clear();
|
|
|
|
| 6680 |
const p = pa();
|
| 6681 |
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;
|
| 6682 |
}
|
| 6683 |
+
const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, levelUpHero, setHeroSkills, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
|
| 6684 |
if (typeof window !== "undefined") {
|
| 6685 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 6686 |
window.__combo = ctrl;
|
web/enemiesSandbox.js
CHANGED
|
@@ -809,6 +809,10 @@ function templateFor(unit) {
|
|
| 809 |
}
|
| 810 |
function makeActor(unit, team, id, slot) {
|
| 811 |
const tpl = templateFor(unit);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
const p = FORMATION[slot % FORMATION.length];
|
| 813 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 814 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
@@ -824,15 +828,15 @@ function makeActor(unit, team, id, slot) {
|
|
| 824 |
role: tpl.role,
|
| 825 |
rank: unit.rank ?? 12,
|
| 826 |
armor: tpl.armor ?? 0,
|
| 827 |
-
weapon
|
| 828 |
moveSpeed: tpl.moveSpeed,
|
| 829 |
preferredRange: tpl.preferredRange,
|
| 830 |
radius: radiusOf(unit, tpl),
|
| 831 |
maxEnergy: tpl.maxEnergy,
|
| 832 |
energyRegen: tpl.energyRegen,
|
| 833 |
-
baseMaxHp:
|
| 834 |
-
maxHp
|
| 835 |
-
hp:
|
| 836 |
energy: tpl.maxEnergy,
|
| 837 |
adrenaline: 0,
|
| 838 |
bar,
|
|
|
|
| 809 |
}
|
| 810 |
function makeActor(unit, team, id, slot) {
|
| 811 |
const tpl = templateFor(unit);
|
| 812 |
+
const hpMul = unit.hpMul ?? unit.statMul ?? 1;
|
| 813 |
+
const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
|
| 814 |
+
const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
|
| 815 |
+
const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
|
| 816 |
const p = FORMATION[slot % FORMATION.length];
|
| 817 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 818 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
|
|
| 828 |
role: tpl.role,
|
| 829 |
rank: unit.rank ?? 12,
|
| 830 |
armor: tpl.armor ?? 0,
|
| 831 |
+
weapon,
|
| 832 |
moveSpeed: tpl.moveSpeed,
|
| 833 |
preferredRange: tpl.preferredRange,
|
| 834 |
radius: radiusOf(unit, tpl),
|
| 835 |
maxEnergy: tpl.maxEnergy,
|
| 836 |
energyRegen: tpl.energyRegen,
|
| 837 |
+
baseMaxHp: maxHp,
|
| 838 |
+
maxHp,
|
| 839 |
+
hp: maxHp,
|
| 840 |
energy: tpl.maxEnergy,
|
| 841 |
adrenaline: 0,
|
| 842 |
bar,
|
web/engine.js
CHANGED
|
@@ -783,6 +783,10 @@ function templateFor(unit) {
|
|
| 783 |
}
|
| 784 |
function makeActor(unit, team, id, slot) {
|
| 785 |
const tpl = templateFor(unit);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
const p = FORMATION[slot % FORMATION.length];
|
| 787 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 788 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
@@ -798,15 +802,15 @@ function makeActor(unit, team, id, slot) {
|
|
| 798 |
role: tpl.role,
|
| 799 |
rank: unit.rank ?? 12,
|
| 800 |
armor: tpl.armor ?? 0,
|
| 801 |
-
weapon
|
| 802 |
moveSpeed: tpl.moveSpeed,
|
| 803 |
preferredRange: tpl.preferredRange,
|
| 804 |
radius: radiusOf(unit, tpl),
|
| 805 |
maxEnergy: tpl.maxEnergy,
|
| 806 |
energyRegen: tpl.energyRegen,
|
| 807 |
-
baseMaxHp:
|
| 808 |
-
maxHp
|
| 809 |
-
hp:
|
| 810 |
energy: tpl.maxEnergy,
|
| 811 |
adrenaline: 0,
|
| 812 |
bar,
|
|
|
|
| 783 |
}
|
| 784 |
function makeActor(unit, team, id, slot) {
|
| 785 |
const tpl = templateFor(unit);
|
| 786 |
+
const hpMul = unit.hpMul ?? unit.statMul ?? 1;
|
| 787 |
+
const dmgMul = unit.dmgMul ?? unit.statMul ?? 1;
|
| 788 |
+
const maxHp = Math.max(1, Math.round(tpl.maxHp * hpMul));
|
| 789 |
+
const weapon = { ...tpl.weapon, min: Math.max(1, Math.round(tpl.weapon.min * dmgMul)), max: Math.max(1, Math.round(tpl.weapon.max * dmgMul)) };
|
| 790 |
const p = FORMATION[slot % FORMATION.length];
|
| 791 |
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 792 |
const bar = (unit.skills || []).map((s) => s && typeof s === "object" ? s : skillById(s)).filter(Boolean);
|
|
|
|
| 802 |
role: tpl.role,
|
| 803 |
rank: unit.rank ?? 12,
|
| 804 |
armor: tpl.armor ?? 0,
|
| 805 |
+
weapon,
|
| 806 |
moveSpeed: tpl.moveSpeed,
|
| 807 |
preferredRange: tpl.preferredRange,
|
| 808 |
radius: radiusOf(unit, tpl),
|
| 809 |
maxEnergy: tpl.maxEnergy,
|
| 810 |
energyRegen: tpl.energyRegen,
|
| 811 |
+
baseMaxHp: maxHp,
|
| 812 |
+
maxHp,
|
| 813 |
+
hp: maxHp,
|
| 814 |
energy: tpl.maxEnergy,
|
| 815 |
adrenaline: 0,
|
| 816 |
bar,
|
web/progression.js
CHANGED
|
@@ -62,6 +62,30 @@ export function enemyXpValue(enemy) {
|
|
| 62 |
return ENEMY_XP.normal
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
// Append a capped, structured event to a persona's log (newest last). Used for the chat
|
| 66 |
// "what's happening to you" context (woid-style perception log) and the level-up feed.
|
| 67 |
const MAX_EVENTS = 60
|
|
|
|
| 62 |
return ENEMY_XP.normal
|
| 63 |
}
|
| 64 |
|
| 65 |
+
// Combat stat scaling from level + spent attribute points. rank feeds the engine's skill
|
| 66 |
+
// scaling; hpMul/dmgMul scale survivability + damage so leveling actually makes you stronger.
|
| 67 |
+
export function statBonuses(progression) {
|
| 68 |
+
const p = progression || {}
|
| 69 |
+
const lvl = p.level || 1
|
| 70 |
+
const spent = p.spent || { hp: 0, dmg: 0 }
|
| 71 |
+
return {
|
| 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 |
+
|
| 78 |
+
// Spend one attribute point into a stat ('hp' | 'dmg'). Returns a NEW progression object;
|
| 79 |
+
// no-op if no points or an unknown stat.
|
| 80 |
+
export function spendPoint(progression, stat) {
|
| 81 |
+
const p = { level: 1, xp: 0, kills: 0, attributePoints: 0, ...(progression || {}) }
|
| 82 |
+
p.spent = { hp: 0, dmg: 0, ...(p.spent || {}) }
|
| 83 |
+
if ((p.attributePoints || 0) <= 0 || !['hp', 'dmg'].includes(stat)) return p
|
| 84 |
+
p.attributePoints -= 1
|
| 85 |
+
p.spent[stat] = (p.spent[stat] || 0) + 1
|
| 86 |
+
return p
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
// Append a capped, structured event to a persona's log (newest last). Used for the chat
|
| 90 |
// "what's happening to you" context (woid-style perception log) and the level-up feed.
|
| 91 |
const MAX_EVENTS = 60
|
web/tiny.js
CHANGED
|
@@ -16,7 +16,7 @@ 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 { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
| 22 |
import { generatePortrait } from '/web/imagen.js'
|
|
@@ -229,6 +229,9 @@ const buildPlayer = (chars, p) => {
|
|
| 229 |
const equipped = resolveEquipped(p)
|
| 230 |
const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
|
| 231 |
if (equipped.length) unit.skills = equipped
|
|
|
|
|
|
|
|
|
|
| 232 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
|
| 233 |
}
|
| 234 |
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)'
|
|
@@ -383,7 +386,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 383 |
if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
|
| 384 |
patchPersona(id, { progression: prog, events })
|
| 385 |
currentHero = getPersona(id)
|
| 386 |
-
if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love') }
|
| 387 |
if (sheetOpen) renderSheet()
|
| 388 |
}
|
| 389 |
// A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
|
|
@@ -549,6 +552,28 @@ whenEl('battle-stage', async (el) => {
|
|
| 549 |
} catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e); busy = false; go.disabled = false; ta.disabled = false }
|
| 550 |
})
|
| 551 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
const renderSheet = () => {
|
| 553 |
sheet.innerHTML = ''
|
| 554 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
@@ -573,7 +598,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 573 |
const meta = document.createElement('div'); meta.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#9aa4b2;margin-bottom:10px'
|
| 574 |
const kills = document.createElement('span'); kills.textContent = `⚔ ${prog.kills} kills`
|
| 575 |
meta.append(kills)
|
| 576 |
-
if (prog.attributePoints > 0) { const pts = document.createElement('
|
| 577 |
sheet.append(meta)
|
| 578 |
}
|
| 579 |
// Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
|
|
|
|
| 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, statBonuses, spendPoint } from '/web/progression.js'
|
| 20 |
import { getSkillIcon, getSkillArt, putSkillArt } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
| 22 |
import { generatePortrait } from '/web/imagen.js'
|
|
|
|
| 229 |
const equipped = resolveEquipped(p)
|
| 230 |
const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
|
| 231 |
if (equipped.length) unit.skills = equipped
|
| 232 |
+
// Leveling makes the fighter stronger: level + spent points → rank (skill scaling) + HP/damage.
|
| 233 |
+
const sb = statBonuses(p?.progression)
|
| 234 |
+
unit.rank = sb.rank; unit.hpMul = sb.hpMul; unit.dmgMul = sb.dmgMul
|
| 235 |
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
|
| 236 |
}
|
| 237 |
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)'
|
|
|
|
| 386 |
if (res.leveledUp) events = appendEvent(events, 'level_up', { level: prog.level, ts: Date.now() })
|
| 387 |
patchPersona(id, { progression: prog, events })
|
| 388 |
currentHero = getPersona(id)
|
| 389 |
+
if (res.leveledUp) { showLevelUp(prog); comboCtrl?.heroEmote?.('love'); comboCtrl?.levelUpHero?.() }
|
| 390 |
if (sheetOpen) renderSheet()
|
| 391 |
}
|
| 392 |
// A brief level-up banner over the canvas (mobile-friendly: centered, auto-dismiss).
|
|
|
|
| 552 |
} catch (e) { status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e); busy = false; go.disabled = false; ta.disabled = false }
|
| 553 |
})
|
| 554 |
}
|
| 555 |
+
// Spend attribute points (earned on level-up) into +HP or +Damage. Applies on next spawn.
|
| 556 |
+
const openSpend = () => {
|
| 557 |
+
const { sh } = openToolSheet('Spend attribute points')
|
| 558 |
+
const render = () => {
|
| 559 |
+
sh.querySelectorAll('.spend-dyn').forEach((n) => n.remove())
|
| 560 |
+
const pr = getPersona(currentHero.id).progression
|
| 561 |
+
const sp = pr.spent || { hp: 0, dmg: 0 }
|
| 562 |
+
const info = document.createElement('div'); info.className = 'spend-dyn'; info.style.cssText = 'color:#9aa4b2;font-size:12px;margin-bottom:10px'
|
| 563 |
+
info.textContent = `${pr.attributePoints} point${pr.attributePoints !== 1 ? 's' : ''} to spend · current: +${Math.round((sp.hp || 0) * 5)}% HP, +${Math.round((sp.dmg || 0) * 5)}% DMG`
|
| 564 |
+
sh.append(info)
|
| 565 |
+
const mk = (label, stat) => {
|
| 566 |
+
const b = document.createElement('button'); b.className = 'spend-dyn'; b.type = 'button'; b.textContent = label
|
| 567 |
+
const none = pr.attributePoints <= 0; b.disabled = none
|
| 568 |
+
b.style.cssText = `width:100%;padding:12px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:${none ? 'default' : 'pointer'};margin-bottom:8px;opacity:${none ? 0.5 : 1}`
|
| 569 |
+
b.addEventListener('click', () => { const np = spendPoint(getPersona(currentHero.id).progression, stat); patchPersona(currentHero.id, { progression: np }); currentHero = getPersona(currentHero.id); render(); renderSheet() })
|
| 570 |
+
sh.append(b)
|
| 571 |
+
}
|
| 572 |
+
mk('+5% Max HP', 'hp'); mk('+5% Damage', 'dmg')
|
| 573 |
+
const note = document.createElement('div'); note.className = 'spend-dyn'; note.style.cssText = 'color:#6b7686;font-size:11px;margin-top:4px'; note.textContent = 'Takes full effect on your next spawn.'; sh.append(note)
|
| 574 |
+
}
|
| 575 |
+
render()
|
| 576 |
+
}
|
| 577 |
const renderSheet = () => {
|
| 578 |
sheet.innerHTML = ''
|
| 579 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
|
|
| 598 |
const meta = document.createElement('div'); meta.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#9aa4b2;margin-bottom:10px'
|
| 599 |
const kills = document.createElement('span'); kills.textContent = `⚔ ${prog.kills} kills`
|
| 600 |
meta.append(kills)
|
| 601 |
+
if (prog.attributePoints > 0) { const pts = document.createElement('button'); pts.type = 'button'; pts.textContent = `★ ${prog.attributePoints} point${prog.attributePoints > 1 ? 's' : ''} — spend`; pts.style.cssText = 'background:none;border:none;color:#ffe082;font:inherit;cursor:pointer;text-decoration:underline'; pts.addEventListener('click', openSpend); meta.append(pts) }
|
| 602 |
sheet.append(meta)
|
| 603 |
}
|
| 604 |
// Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
|