Spaces:
Running
Running
Phase 2: Skill Forge writes real engine skills + character-page detail screen
Browse filesskillSchema.js validates/clamps model output into an engine-resolvable CB skill
(safe op/condition whitelist, 9000+ ids, effect summaries, equip resolution). The
forge panel now emits that IR, validates with one retry + fallback, paints a Klein
icon, and persists the skill onto the hero (customSkills/learnedSkills). The Game
character sheet lists forged skills as cards; tapping opens a detail bottom-sheet
with a lazily-painted Klein action illustration of the hero performing the skill,
flavor, effects, and equip/unequip (max 3). Equipped skills flow into live combat.
Rebuilt engine/combo/sandbox bundles for the object-or-id skill bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/classesSandbox.js +1 -1
- web/comboBattler.js +1 -1
- web/enemiesSandbox.js +1 -1
- web/engine.js +1 -1
- web/skillForgePanel.js +94 -40
- web/skillSchema.js +103 -0
- web/tiny.js +69 -1
web/classesSandbox.js
CHANGED
|
@@ -978,7 +978,7 @@ 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(skillById).filter(Boolean);
|
| 982 |
return {
|
| 983 |
id,
|
| 984 |
team,
|
|
|
|
| 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);
|
| 982 |
return {
|
| 983 |
id,
|
| 984 |
team,
|
web/comboBattler.js
CHANGED
|
@@ -5058,7 +5058,7 @@ 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(skillById).filter(Boolean);
|
| 5062 |
return {
|
| 5063 |
id,
|
| 5064 |
team,
|
|
|
|
| 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);
|
| 5062 |
return {
|
| 5063 |
id,
|
| 5064 |
team,
|
web/enemiesSandbox.js
CHANGED
|
@@ -811,7 +811,7 @@ 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(skillById).filter(Boolean);
|
| 815 |
return {
|
| 816 |
id,
|
| 817 |
team,
|
|
|
|
| 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);
|
| 815 |
return {
|
| 816 |
id,
|
| 817 |
team,
|
web/engine.js
CHANGED
|
@@ -785,7 +785,7 @@ 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(skillById).filter(Boolean);
|
| 789 |
return {
|
| 790 |
id,
|
| 791 |
team,
|
|
|
|
| 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);
|
| 789 |
return {
|
| 790 |
id,
|
| 791 |
team,
|
web/skillForgePanel.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
| 1 |
-
// Skill Forge — the Sandbox surface that uses the Coding Model to author a
|
| 2 |
-
// skill for a chosen hero. Pick a recruited persona, describe the skill
|
| 3 |
-
// the coding model
|
| 4 |
-
//
|
| 5 |
-
//
|
| 6 |
import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
|
| 7 |
-
import { listPersonas, getPersona, onRosterChange } from '/web/personaStore.js'
|
| 8 |
import { stripThink, stripThinkFinal } from '/web/personaPrompts.js'
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
function el(tag, props = {}, kids = []) {
|
| 11 |
const n = document.createElement(tag)
|
| 12 |
for (const [k, v] of Object.entries(props)) {
|
| 13 |
if (k === 'class') n.className = v
|
|
|
|
| 14 |
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 15 |
else if (v != null) n.setAttribute(k, v)
|
| 16 |
}
|
|
@@ -18,13 +22,21 @@ function el(tag, props = {}, kids = []) {
|
|
| 18 |
return n
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
| 21 |
const SYSTEM = [
|
| 22 |
-
'You are the Skill Forge for a fantasy auto-battler.
|
| 23 |
-
'
|
| 24 |
-
'
|
| 25 |
-
'
|
| 26 |
-
'
|
| 27 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
].join(' ')
|
| 29 |
|
| 30 |
function personaBlock(p) {
|
|
@@ -37,26 +49,31 @@ function personaBlock(p) {
|
|
| 37 |
].filter(Boolean).join('\n')
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
export function mountSkillForgePanel(host) {
|
| 41 |
const sel = el('select', { class: 'persona-input skillforge-hero' })
|
| 42 |
const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
|
| 43 |
placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' })
|
| 44 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill')
|
| 45 |
const status = el('div', { class: 'persona-status' })
|
| 46 |
-
const
|
| 47 |
const empty = el('div', { class: 'persona-roster-empty' },
|
| 48 |
'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
|
| 49 |
|
| 50 |
-
// "Show thinking" reveals the reasoning models' <think> trace (Nemotron, BLS) in the debug
|
| 51 |
-
// panel below; off by default so forging stays fast/clean. (Mellum2 has no reasoning.)
|
| 52 |
const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' })
|
| 53 |
-
const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' },
|
| 54 |
-
[thinkChk, ' show model thinking'])
|
| 55 |
const dbgEl = el('pre', { class: 'persona-think' })
|
| 56 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button',
|
| 57 |
onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy')
|
| 58 |
-
const dbgWrap = el('details', { class: 'persona-think-wrap' },
|
| 59 |
-
[el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl])
|
| 60 |
dbgWrap.style.display = thinkChk.checked ? '' : 'none'
|
| 61 |
|
| 62 |
const controls = el('aside', { class: 'persona-controls skillforge' }, [
|
|
@@ -67,7 +84,7 @@ export function mountSkillForgePanel(host) {
|
|
| 67 |
thinkLabel,
|
| 68 |
el('div', { class: 'persona-prompt-actions' }, [btn]),
|
| 69 |
status,
|
| 70 |
-
|
| 71 |
dbgWrap,
|
| 72 |
])
|
| 73 |
host.append(controls)
|
|
@@ -84,9 +101,37 @@ export function mountSkillForgePanel(host) {
|
|
| 84 |
sel.style.display = none ? 'none' : ''
|
| 85 |
btn.disabled = none
|
| 86 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
function
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
let running = false
|
|
@@ -97,27 +142,36 @@ export function mountSkillForgePanel(host) {
|
|
| 97 |
const ask = req.value.trim()
|
| 98 |
if (!ask) { status.textContent = 'Describe the skill you want.'; return }
|
| 99 |
running = true; status.dataset.busy = '1'; btn.disabled = true
|
| 100 |
-
|
| 101 |
-
status.textContent = `Forging with ${currentCodingModel().label}…`
|
| 102 |
-
const user = `${personaBlock(p)}\n\nSkill to create: ${ask}`
|
| 103 |
const showThink = thinkChk.checked
|
| 104 |
-
let raw = ''
|
| 105 |
try {
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
})
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
status.textContent = `Done${tps}.`
|
| 121 |
} catch (e) {
|
| 122 |
status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
|
| 123 |
} finally {
|
|
|
|
| 1 |
+
// Skill Forge — the Sandbox surface that uses the Coding Model to author a REAL, engine-
|
| 2 |
+
// resolvable combat skill for a chosen hero. Pick a recruited persona, describe the skill,
|
| 3 |
+
// and the coding model writes a CB skill object (validated + clamped by skillSchema.js) that
|
| 4 |
+
// the battle engine can actually run. The forge then paints a Klein icon, saves the skill onto
|
| 5 |
+
// the hero (customSkills + learnedSkills), and shows a card you can view on the character page.
|
| 6 |
import { streamCoding, currentCodingModel, onCodingModelChange } from '/web/codingModel.js'
|
| 7 |
+
import { listPersonas, getPersona, onRosterChange, patchPersona, putSkillIcon, getSkillIcon } from '/web/personaStore.js'
|
| 8 |
import { stripThink, stripThinkFinal } from '/web/personaPrompts.js'
|
| 9 |
+
import { validateSkill, effectSummary, SAFE_OPS, SAFE_CONDITIONS } from '/web/skillSchema.js'
|
| 10 |
+
import { generatePortrait } from '/web/imagen.js'
|
| 11 |
+
import { appendEvent } from '/web/progression.js'
|
| 12 |
|
| 13 |
function el(tag, props = {}, kids = []) {
|
| 14 |
const n = document.createElement(tag)
|
| 15 |
for (const [k, v] of Object.entries(props)) {
|
| 16 |
if (k === 'class') n.className = v
|
| 17 |
+
else if (k === 'style') n.style.cssText = v
|
| 18 |
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
|
| 19 |
else if (v != null) n.setAttribute(k, v)
|
| 20 |
}
|
|
|
|
| 22 |
return n
|
| 23 |
}
|
| 24 |
|
| 25 |
+
// The forge contract: emit ONE fenced json skill object the engine can resolve. We constrain
|
| 26 |
+
// the op + condition vocabulary to skillSchema's whitelist so the result is always runnable.
|
| 27 |
const SYSTEM = [
|
| 28 |
+
'You are the Skill Forge for a fantasy auto-battler. Author ONE combat skill for a specific',
|
| 29 |
+
'hero, tailored to their class, story and personality. Respond with a SINGLE fenced ```json',
|
| 30 |
+
'block (no prose outside it) of the form:',
|
| 31 |
+
'{"name": string, "flavor": string (one vivid in-world sentence),',
|
| 32 |
+
'"category": "melee_attack"|"ranged_attack"|"spell"|"shout"|"stance",',
|
| 33 |
+
'"target": "foe"|"self"|"ally"|"area", "cost": int, "recharge": int seconds,',
|
| 34 |
+
'"effects": [ {"op": <op>, ...args} ] }.',
|
| 35 |
+
`Allowed effect ops: ${SAFE_OPS.join(', ')}.`,
|
| 36 |
+
'damage/bonus_damage/heal/life_steal take {"amount": int}. apply_condition takes',
|
| 37 |
+
`{"condition": one of [${SAFE_CONDITIONS.join(', ')}], "duration": int seconds}.`,
|
| 38 |
+
'knockdown takes {"duration": int}. interrupt and lose_all_adrenaline take no args.',
|
| 39 |
+
'Use 1-3 effects. Keep it concise, flavourful and balanced. Output ONLY the json block.',
|
| 40 |
].join(' ')
|
| 41 |
|
| 42 |
function personaBlock(p) {
|
|
|
|
| 49 |
].filter(Boolean).join('\n')
|
| 50 |
}
|
| 51 |
|
| 52 |
+
// Pull the first JSON object out of model text (fenced or bare).
|
| 53 |
+
function parseSkillJson(text) {
|
| 54 |
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
| 55 |
+
const body = fence ? fence[1] : text
|
| 56 |
+
const a = body.indexOf('{'), b = body.lastIndexOf('}')
|
| 57 |
+
if (a < 0 || b <= a) return null
|
| 58 |
+
try { return JSON.parse(body.slice(a, b + 1)) } catch { return null }
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
export function mountSkillForgePanel(host) {
|
| 62 |
const sel = el('select', { class: 'persona-input skillforge-hero' })
|
| 63 |
const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4,
|
| 64 |
placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' })
|
| 65 |
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill')
|
| 66 |
const status = el('div', { class: 'persona-status' })
|
| 67 |
+
const card = el('div', { class: 'skillforge-card', style: 'display:none;margin-top:10px;border:1px solid #2a3340;border-radius:12px;padding:12px;background:rgba(20,24,33,.6)' })
|
| 68 |
const empty = el('div', { class: 'persona-roster-empty' },
|
| 69 |
'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.')
|
| 70 |
|
|
|
|
|
|
|
| 71 |
const thinkChk = el('input', { type: 'checkbox', class: 'skillforge-think' })
|
| 72 |
+
const thinkLabel = el('label', { class: 'persona-label skillforge-think-label' }, [thinkChk, ' show model thinking'])
|
|
|
|
| 73 |
const dbgEl = el('pre', { class: 'persona-think' })
|
| 74 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button',
|
| 75 |
onclick: () => navigator.clipboard?.writeText(dbgEl.textContent || '') }, 'copy')
|
| 76 |
+
const dbgWrap = el('details', { class: 'persona-think-wrap' }, [el('summary', {}, 'model thinking / raw'), copyBtn, dbgEl])
|
|
|
|
| 77 |
dbgWrap.style.display = thinkChk.checked ? '' : 'none'
|
| 78 |
|
| 79 |
const controls = el('aside', { class: 'persona-controls skillforge' }, [
|
|
|
|
| 84 |
thinkLabel,
|
| 85 |
el('div', { class: 'persona-prompt-actions' }, [btn]),
|
| 86 |
status,
|
| 87 |
+
card,
|
| 88 |
dbgWrap,
|
| 89 |
])
|
| 90 |
host.append(controls)
|
|
|
|
| 101 |
sel.style.display = none ? 'none' : ''
|
| 102 |
btn.disabled = none
|
| 103 |
}
|
| 104 |
+
function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
|
| 105 |
+
|
| 106 |
+
// Stream the coding model once; return the validated skill (or null + reason). `extra` lets a
|
| 107 |
+
// retry feed the validation errors back to the model.
|
| 108 |
+
async function generateSkill(p, ask, showThink, extra = '') {
|
| 109 |
+
const user = `${personaBlock(p)}\n\nSkill to create: ${ask}${extra}`
|
| 110 |
+
let raw = ''
|
| 111 |
+
const { } = await streamCoding(SYSTEM, user, {
|
| 112 |
+
maxTokens: 512, temperature: 0.6, think: showThink,
|
| 113 |
+
onToken: (t) => { raw += t; if (showThink) { dbgEl.textContent = raw; dbgWrap.open = true; dbgEl.scrollTop = dbgEl.scrollHeight } },
|
| 114 |
+
})
|
| 115 |
+
const clean = stripThinkFinal(raw)
|
| 116 |
+
const parsed = parseSkillJson(clean)
|
| 117 |
+
const v = validateSkill(parsed, { persona: p })
|
| 118 |
+
return { v, clean }
|
| 119 |
+
}
|
| 120 |
|
| 121 |
+
function renderCard(skill, iconBlob) {
|
| 122 |
+
card.style.display = ''
|
| 123 |
+
card.replaceChildren()
|
| 124 |
+
const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
|
| 125 |
+
const icon = el('div', { style: 'width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
|
| 126 |
+
if (iconBlob) icon.style.backgroundImage = `url(${URL.createObjectURL(iconBlob)})`
|
| 127 |
+
const meta = el('div', {}, [
|
| 128 |
+
el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
|
| 129 |
+
el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
|
| 130 |
+
])
|
| 131 |
+
head.append(icon, meta)
|
| 132 |
+
const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
|
| 133 |
+
const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, '✓ Learned — view it on the hero’s character sheet.')
|
| 134 |
+
card.append(head, flav, saved)
|
| 135 |
}
|
| 136 |
|
| 137 |
let running = false
|
|
|
|
| 142 |
const ask = req.value.trim()
|
| 143 |
if (!ask) { status.textContent = 'Describe the skill you want.'; return }
|
| 144 |
running = true; status.dataset.busy = '1'; btn.disabled = true
|
| 145 |
+
card.style.display = 'none'; dbgEl.textContent = ''
|
|
|
|
|
|
|
| 146 |
const showThink = thinkChk.checked
|
|
|
|
| 147 |
try {
|
| 148 |
+
status.textContent = `Forging with ${currentCodingModel().label}…`
|
| 149 |
+
let { v } = await generateSkill(p, ask, showThink)
|
| 150 |
+
if (!v.ok) {
|
| 151 |
+
status.textContent = 'Refining (invalid skill, retrying)…'
|
| 152 |
+
const fix = `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.`
|
| 153 |
+
;({ v } = await generateSkill(p, ask, showThink, fix))
|
| 154 |
+
}
|
| 155 |
+
if (!v.ok) { status.textContent = 'Forge failed to produce a valid skill: ' + v.errors.join('; '); return }
|
| 156 |
+
const skill = v.skill
|
| 157 |
+
// Paint the icon (best-effort; a skill without art is still learned).
|
| 158 |
+
status.textContent = 'Painting skill icon…'
|
| 159 |
+
let iconBlob = null
|
| 160 |
+
try {
|
| 161 |
+
iconBlob = await generatePortrait(
|
| 162 |
+
`fantasy game skill icon, "${skill.name}": ${skill.flavor}. ${p.unitClass || ''} ability, dramatic, centered emblem, painterly, square`,
|
| 163 |
+
{ seed: skill.id })
|
| 164 |
+
if (iconBlob) await putSkillIcon(skill.id, iconBlob)
|
| 165 |
+
} catch { /* art is optional */ }
|
| 166 |
+
// Persist onto the hero.
|
| 167 |
+
const fresh = getPersona(p.id) || p
|
| 168 |
+
patchPersona(p.id, {
|
| 169 |
+
customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
|
| 170 |
+
learnedSkills: [...(fresh.learnedSkills || []), skill.id],
|
| 171 |
+
events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
|
| 172 |
})
|
| 173 |
+
renderCard(skill, iconBlob)
|
| 174 |
+
status.textContent = 'Done.'
|
|
|
|
| 175 |
} catch (e) {
|
| 176 |
status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e)
|
| 177 |
} finally {
|
web/skillSchema.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Skill Forge validator — turns a model-authored skill into a SAFE, engine-resolvable
|
| 2 |
+
// CB skill object. The combat resolver (auto-battler src/engine/teamBattle.js) switches on
|
| 3 |
+
// effect `op`s and reads {category, target, cost, recharge, effects[]}; anything outside the
|
| 4 |
+
// whitelist here is dropped or clamped so a forged skill can never crash the resolver or be
|
| 5 |
+
// wildly unbalanced. Mirrors the op/condition vocabulary in teamBattle.js — keep in sync.
|
| 6 |
+
|
| 7 |
+
// Effect ops the resolver understands AND we consider safe to expose to generation.
|
| 8 |
+
// Each entry clamps/normalizes its payload to engine-ready numbers.
|
| 9 |
+
const NUM = (v, lo, hi, dflt) => {
|
| 10 |
+
let n = typeof v === 'number' ? v : (v && Array.isArray(v.scale) ? (v.scale[1] ?? v.scale[0]) : Number(v))
|
| 11 |
+
if (!Number.isFinite(n)) n = dflt
|
| 12 |
+
return Math.max(lo, Math.min(hi, Math.round(n)))
|
| 13 |
+
}
|
| 14 |
+
const CONDITIONS = ['bleeding', 'poison', 'burning', 'deepWound', 'weakness', 'crippled', 'blind', 'dazed']
|
| 15 |
+
const SCOPES = ['self', 'party', 'nearby', 'area', 'target_and_adjacent', 'adjacent_to_target']
|
| 16 |
+
|
| 17 |
+
const OPS = {
|
| 18 |
+
damage: (e) => ({ op: 'damage', amount: NUM(e.amount, 1, 60, 15) }),
|
| 19 |
+
bonus_damage: (e) => ({ op: 'bonus_damage', amount: NUM(e.amount, 1, 40, 12) }),
|
| 20 |
+
heal: (e) => ({ op: 'heal', amount: NUM(e.amount, 1, 60, 20) }),
|
| 21 |
+
life_steal: (e) => ({ op: 'life_steal', amount: NUM(e.amount, 1, 40, 12) }),
|
| 22 |
+
apply_condition: (e) => CONDITIONS.includes(e.condition)
|
| 23 |
+
? { op: 'apply_condition', condition: e.condition, duration: NUM(e.duration, 1, 25, 8), ...(SCOPES.includes(e.scope) ? { scope: e.scope } : {}) }
|
| 24 |
+
: null,
|
| 25 |
+
knockdown: (e) => ({ op: 'knockdown', duration: NUM(e.duration, 1, 5, 2) }),
|
| 26 |
+
interrupt: () => ({ op: 'interrupt' }),
|
| 27 |
+
lose_all_adrenaline: () => ({ op: 'lose_all_adrenaline' }),
|
| 28 |
+
}
|
| 29 |
+
const CATEGORIES = ['melee_attack', 'ranged_attack', 'spell', 'shout', 'stance']
|
| 30 |
+
const TARGETS = ['foe', 'self', 'ally', 'area']
|
| 31 |
+
|
| 32 |
+
export const SAFE_OPS = Object.keys(OPS)
|
| 33 |
+
export const SAFE_CONDITIONS = CONDITIONS.slice()
|
| 34 |
+
|
| 35 |
+
// A fresh id that won't collide with the canon skills (ids ~0–700) or the hero's own forged
|
| 36 |
+
// skills. Forged ids live in the 9000+ band.
|
| 37 |
+
export function nextSkillId(persona) {
|
| 38 |
+
const used = Object.keys((persona && persona.customSkills) || {}).map((k) => +k).filter((n) => n >= 9000)
|
| 39 |
+
return used.length ? Math.max(...used) + 1 : 9000
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Validate + normalize a raw model object into an engine-ready skill. Returns
|
| 43 |
+
// { ok, skill, errors }. `skill` is always engine-safe when ok is true.
|
| 44 |
+
export function validateSkill(raw, { persona, profession } = {}) {
|
| 45 |
+
const errors = []
|
| 46 |
+
if (!raw || typeof raw !== 'object') return { ok: false, skill: null, errors: ['not an object'] }
|
| 47 |
+
const name = String(raw.name || '').trim().slice(0, 40)
|
| 48 |
+
if (!name) errors.push('missing name')
|
| 49 |
+
const category = CATEGORIES.includes(raw.category) ? raw.category : 'melee_attack'
|
| 50 |
+
const target = TARGETS.includes(raw.target) ? raw.target : 'foe'
|
| 51 |
+
const rawEffects = Array.isArray(raw.effects) ? raw.effects : []
|
| 52 |
+
const effects = rawEffects.map((e) => (e && OPS[e.op] ? OPS[e.op](e) : null)).filter(Boolean).slice(0, 3)
|
| 53 |
+
if (!effects.length) errors.push('no valid effects')
|
| 54 |
+
// Cost: keep it cheap enough to actually fire. Casters pay energy, fighters adrenaline.
|
| 55 |
+
const isCaster = category === 'spell'
|
| 56 |
+
const cost = isCaster ? { energy: NUM(raw.cost?.energy ?? raw.cost, 1, 15, 5) }
|
| 57 |
+
: { adrenaline: NUM(raw.cost?.adrenaline ?? raw.cost, 0, 10, 4) }
|
| 58 |
+
const recharge = NUM(raw.recharge ?? raw.cooldown, 0, 30, isCaster ? 4 : 0)
|
| 59 |
+
const flavor = String(raw.flavor || raw.description || '').trim().slice(0, 200)
|
| 60 |
+
if (errors.length) return { ok: false, skill: null, errors }
|
| 61 |
+
const skill = {
|
| 62 |
+
id: nextSkillId(persona),
|
| 63 |
+
name,
|
| 64 |
+
profession: profession || (persona && persona.unitClass) || 'Warrior',
|
| 65 |
+
attribute: 'Forged',
|
| 66 |
+
category, target,
|
| 67 |
+
cost, cast: isCaster ? NUM(raw.cast, 0, 3, 1) : 0, recharge,
|
| 68 |
+
requires: category === 'melee_attack' || category === 'ranged_attack' ? ['on_hit'] : [],
|
| 69 |
+
effects,
|
| 70 |
+
// ── metadata (ignored by the resolver, used by the UI) ──
|
| 71 |
+
custom: true, flavor,
|
| 72 |
+
}
|
| 73 |
+
return { ok: true, skill, errors: [] }
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Human one-line effect summary for skill cards (mirrors the engine's label style).
|
| 77 |
+
export function effectSummary(skill) {
|
| 78 |
+
if (!skill || !Array.isArray(skill.effects)) return ''
|
| 79 |
+
const parts = skill.effects.map((e) => {
|
| 80 |
+
switch (e.op) {
|
| 81 |
+
case 'damage': case 'bonus_damage': return `+${e.amount} dmg`
|
| 82 |
+
case 'heal': return `+${e.amount} heal`
|
| 83 |
+
case 'life_steal': return `${e.amount} life steal`
|
| 84 |
+
case 'apply_condition': return e.condition
|
| 85 |
+
case 'knockdown': return 'knockdown'
|
| 86 |
+
case 'interrupt': return 'interrupt'
|
| 87 |
+
case 'lose_all_adrenaline': return 'spend adrenaline'
|
| 88 |
+
default: return e.op
|
| 89 |
+
}
|
| 90 |
+
})
|
| 91 |
+
return parts.join(' · ')
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Resolve a persona's equipped skill ids into engine-ready objects (custom objects pass
|
| 95 |
+
// through; numeric ids are left for the engine to resolve against CB_SKILLS).
|
| 96 |
+
export function resolveEquipped(persona) {
|
| 97 |
+
if (!persona) return []
|
| 98 |
+
const custom = persona.customSkills || {}
|
| 99 |
+
return (persona.equippedSkills || [])
|
| 100 |
+
.map((id) => custom[id] || (typeof id === 'number' ? id : null))
|
| 101 |
+
.filter((x) => x != null)
|
| 102 |
+
.slice(0, 3)
|
| 103 |
+
}
|
web/tiny.js
CHANGED
|
@@ -17,6 +17,9 @@ 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'
|
|
@@ -219,7 +222,12 @@ const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player
|
|
| 219 |
// A persona → controllable hero (class → sheets + engine profession).
|
| 220 |
const buildPlayer = (chars, p) => {
|
| 221 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
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)'
|
| 225 |
// Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
|
|
@@ -404,6 +412,45 @@ whenEl('battle-stage', async (el) => {
|
|
| 404 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:44px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer'
|
| 405 |
const sheet = document.createElement('aside')
|
| 406 |
sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
const renderSheet = () => {
|
| 408 |
sheet.innerHTML = ''
|
| 409 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
@@ -444,6 +491,27 @@ whenEl('battle-stage', async (el) => {
|
|
| 444 |
const h = document.createElement('div'); h.textContent = 'Skills'; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:12px 0 6px'; sheet.append(h)
|
| 445 |
skills.forEach((s, i) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:7px 9px;border:1px solid #232b36;border-radius:8px;margin-bottom:6px'; const n = document.createElement('span'); n.textContent = String(i + 1); n.style.cssText = 'width:18px;height:18px;border-radius:50%;background:#222a35;display:flex;align-items:center;justify-content:center;font-size:11px;color:#9aa4b2;flex:none'; const t = document.createElement('span'); t.textContent = s; row.append(n, t); sheet.append(row) })
|
| 446 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
if (currentHero.about) { const a = document.createElement('div'); a.textContent = currentHero.about; a.style.cssText = 'color:#c2c8d2;margin:12px 0;line-height:1.45'; sheet.append(a) }
|
| 448 |
const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'
|
| 449 |
sw.style.cssText = 'width:100%;margin-top:14px;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer'
|
|
|
|
| 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'
|
| 23 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 24 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 25 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
|
|
| 222 |
// A persona → controllable hero (class → sheets + engine profession).
|
| 223 |
const buildPlayer = (chars, p) => {
|
| 224 |
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 225 |
+
// Equipped Skill-Forge skills (objects) flow straight into the engine bar; if none are
|
| 226 |
+
// equipped, leaving skills unset lets comboBattler pick the class's default three.
|
| 227 |
+
const equipped = resolveEquipped(p)
|
| 228 |
+
const unit = { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }
|
| 229 |
+
if (equipped.length) unit.skills = equipped
|
| 230 |
+
return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit }
|
| 231 |
}
|
| 232 |
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)'
|
| 233 |
// Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
|
|
|
|
| 412 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:44px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer'
|
| 413 |
const sheet = document.createElement('aside')
|
| 414 |
sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
|
| 415 |
+
// A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
|
| 416 |
+
// flavor, effects, and an equip/unequip toggle (max 3 equipped).
|
| 417 |
+
let skillSheet = null
|
| 418 |
+
const openSkillDetail = (sk) => {
|
| 419 |
+
skillSheet?.remove()
|
| 420 |
+
const back = document.createElement('div'); back.style.cssText = 'position:absolute;inset:0;z-index:10;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'
|
| 421 |
+
const sh = document.createElement('div'); sh.style.cssText = 'width:min(440px,96vw);max-height:90%;overflow-y:auto;background:#11151c;border:1px solid #2a3340;border-radius:16px 16px 0 0;padding:16px;color:#e8e8e8;box-sizing:border-box'
|
| 422 |
+
const x = document.createElement('button'); x.type = 'button'; x.textContent = '✕'; x.style.cssText = 'float:right;background:none;border:none;color:#9aa4b2;font-size:20px;cursor:pointer'
|
| 423 |
+
const closeDetail = () => { back.remove(); skillSheet = null }
|
| 424 |
+
x.addEventListener('click', closeDetail); sh.append(x)
|
| 425 |
+
const art = document.createElement('div'); art.style.cssText = 'width:100%;aspect-ratio:1/1;border-radius:12px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;margin:6px 0 12px;display:flex;align-items:center;justify-content:center;color:#5a6573;font-size:12px'
|
| 426 |
+
art.textContent = 'summoning the vision…'; sh.append(art)
|
| 427 |
+
const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
|
| 428 |
+
const cat = document.createElement('div'); cat.textContent = `${(sk.category || '').replace('_', ' ')} · ${effectSummary(sk)}`; cat.style.cssText = 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:3px 0 10px'; sh.append(cat)
|
| 429 |
+
if (sk.flavor) { const fl = document.createElement('div'); fl.textContent = sk.flavor; fl.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;margin-bottom:14px'; sh.append(fl) }
|
| 430 |
+
const eqBtn = document.createElement('button'); eqBtn.type = 'button'
|
| 431 |
+
const isEq = () => (getPersona(currentHero.id)?.equippedSkills || []).includes(sk.id)
|
| 432 |
+
const paintEq = () => { const on = isEq(); eqBtn.textContent = on ? '★ Equipped — tap to remove' : '☆ Equip (uses a skill slot)'; eqBtn.style.cssText = `width:100%;padding:12px;border-radius:10px;border:1px solid ${on ? '#c9a227' : '#2a3340'};background:${on ? '#2a2410' : '#1a2230'};color:${on ? '#ffe082' : '#e8e8e8'};font:600 13px var(--tac-font,system-ui);cursor:pointer` }
|
| 433 |
+
eqBtn.addEventListener('click', () => {
|
| 434 |
+
const cur = getPersona(currentHero.id); if (!cur) return
|
| 435 |
+
let eq = (cur.equippedSkills || []).slice()
|
| 436 |
+
if (eq.includes(sk.id)) eq = eq.filter((i) => i !== sk.id)
|
| 437 |
+
else { if (eq.length >= 3) { eqBtn.textContent = 'Slots full (3) — remove one first'; return } eq.push(sk.id) }
|
| 438 |
+
patchPersona(cur.id, { equippedSkills: eq }); currentHero = getPersona(cur.id); paintEq(); renderSheet()
|
| 439 |
+
})
|
| 440 |
+
paintEq(); sh.append(eqBtn)
|
| 441 |
+
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
|
| 442 |
+
back.append(sh); el.append(back); skillSheet = back
|
| 443 |
+
// Action illustration: serve cached, else paint once with Klein and cache.
|
| 444 |
+
getSkillArt(sk.id).then(async (b) => {
|
| 445 |
+
if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})`; return }
|
| 446 |
+
try {
|
| 447 |
+
const prompt = `${currentHero.name || 'A fighter'}, a ${currentHero.unitClass || ''}, performing "${sk.name}": ${sk.flavor || ''}. dynamic action scene, motion and impact, fantasy game art, dramatic lighting`
|
| 448 |
+
const blob = await generatePortrait(prompt, { seed: sk.id + 1 })
|
| 449 |
+
if (blob) { await putSkillArt(sk.id, blob); art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(blob)})` }
|
| 450 |
+
else art.textContent = 'no art'
|
| 451 |
+
} catch { art.textContent = 'art unavailable' }
|
| 452 |
+
})
|
| 453 |
+
}
|
| 454 |
const renderSheet = () => {
|
| 455 |
sheet.innerHTML = ''
|
| 456 |
const close = document.createElement('button'); close.type = 'button'; close.textContent = '✕'
|
|
|
|
| 491 |
const h = document.createElement('div'); h.textContent = 'Skills'; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:12px 0 6px'; sheet.append(h)
|
| 492 |
skills.forEach((s, i) => { const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:7px 9px;border:1px solid #232b36;border-radius:8px;margin-bottom:6px'; const n = document.createElement('span'); n.textContent = String(i + 1); n.style.cssText = 'width:18px;height:18px;border-radius:50%;background:#222a35;display:flex;align-items:center;justify-content:center;font-size:11px;color:#9aa4b2;flex:none'; const t = document.createElement('span'); t.textContent = s; row.append(n, t); sheet.append(row) })
|
| 493 |
}
|
| 494 |
+
// ── Forged skills — tap a card for the detail sheet (action art + equip) ──
|
| 495 |
+
{
|
| 496 |
+
const cs = currentHero.customSkills || {}
|
| 497 |
+
const learned = (currentHero.learnedSkills || []).map((id) => cs[id]).filter(Boolean)
|
| 498 |
+
if (learned.length) {
|
| 499 |
+
const equipped = new Set(currentHero.equippedSkills || [])
|
| 500 |
+
const h = document.createElement('div'); h.textContent = `Forged skills (${equipped.size}/3 equipped)`; h.style.cssText = 'font:600 10px var(--tac-font,system-ui);letter-spacing:.18em;text-transform:uppercase;color:#9aa4b2;margin:16px 0 6px'; sheet.append(h)
|
| 501 |
+
learned.forEach((sk) => {
|
| 502 |
+
const row = document.createElement('button'); row.type = 'button'
|
| 503 |
+
row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:8px;border:1px solid ${equipped.has(sk.id) ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
|
| 504 |
+
const ic = document.createElement('div'); ic.style.cssText = 'width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
|
| 505 |
+
getSkillIcon(sk.id).then((b) => { if (b) ic.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
|
| 506 |
+
const txt = document.createElement('div')
|
| 507 |
+
const nm = document.createElement('div'); nm.textContent = sk.name + (equipped.has(sk.id) ? ' ★' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
|
| 508 |
+
const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
|
| 509 |
+
txt.append(nm, ef); row.append(ic, txt)
|
| 510 |
+
row.addEventListener('click', () => openSkillDetail(sk))
|
| 511 |
+
sheet.append(row)
|
| 512 |
+
})
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
if (currentHero.about) { const a = document.createElement('div'); a.textContent = currentHero.about; a.style.cssText = 'color:#c2c8d2;margin:12px 0;line-height:1.45'; sheet.append(a) }
|
| 516 |
const sw = document.createElement('button'); sw.type = 'button'; sw.textContent = '⇄ Switch / New hero'
|
| 517 |
sw.style.cssText = 'width:100%;margin-top:14px;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#1a2230;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer'
|