Spaces:
Running
Running
| // Skill Forge tab — author a REAL, engine-resolvable combat skill for a chosen hero. The forge | |
| // pipeline (model → validate → Klein icon → persist) lives in skillForge.js and is shared with | |
| // the in-Game character sheet, so a hero can learn skills from either place. | |
| import { currentCodingModel, onCodingModelChange } from '/web/codingModel.js' | |
| import { listPersonas, getPersona, onRosterChange, getSkillIcon } from '/web/personaStore.js' | |
| import { effectSummary } from '/web/skillSchema.js' | |
| import { forgeSkillForHero } from '/web/skillForge.js' | |
| // Loading spinner for a skill whose scene is still painting. Idempotent — tiny.js injects the same | |
| // keyframes/class; we re-inject (id-guarded) so the Forge tab works even before the game module loads. | |
| ;(function injectSkillSpin() { | |
| try { | |
| if (document.getElementById('ta-skill-spin-css')) return | |
| const s = document.createElement('style'); s.id = 'ta-skill-spin-css' | |
| s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}' | |
| document.head.appendChild(s) | |
| } catch { /* ignore */ } | |
| })() | |
| function el(tag, props = {}, kids = []) { | |
| const n = document.createElement(tag) | |
| for (const [k, v] of Object.entries(props)) { | |
| if (k === 'class') n.className = v | |
| else if (k === 'style') n.style.cssText = v | |
| else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v) | |
| else if (v != null) n.setAttribute(k, v) | |
| } | |
| for (const kid of [].concat(kids)) if (kid != null) n.append(kid) | |
| return n | |
| } | |
| export function mountSkillForgePanel(host) { | |
| const sel = el('select', { class: 'persona-input skillforge-hero' }) | |
| const req = el('textarea', { class: 'persona-prompt-edit skillforge-req', rows: 4, | |
| placeholder: 'what skill should this hero learn? (e.g. “a defensive shout that shields nearby allies”)' }) | |
| const btn = el('button', { class: 'persona-go', type: 'button' }, '⚒ Forge skill') | |
| const status = el('div', { class: 'persona-status' }) | |
| 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)' }) | |
| const empty = el('div', { class: 'persona-roster-empty' }, | |
| 'No heroes yet — recruit one in the Personas tab, then come back to forge its skills.') | |
| const controls = el('aside', { class: 'persona-controls skillforge' }, [ | |
| el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, 'Skill Forge'), el('span')]), | |
| el('label', { class: 'persona-label' }, 'Hero'), sel, | |
| empty, | |
| el('label', { class: 'persona-label' }, 'Skill request'), req, | |
| el('div', { class: 'persona-prompt-actions' }, [btn]), | |
| status, | |
| card, | |
| ]) | |
| host.append(controls) | |
| function refreshHeroes() { | |
| const people = listPersonas() | |
| const prev = sel.value | |
| sel.replaceChildren(...people.map((p) => el('option', { value: p.id }, p.name || 'Unnamed hero'))) | |
| if (people.some((p) => p.id === prev)) sel.value = prev | |
| const none = people.length === 0 | |
| empty.style.display = none ? '' : 'none' | |
| sel.style.display = none ? 'none' : '' | |
| btn.disabled = none | |
| } | |
| function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` } | |
| let shownSkillId = null // skill currently in the card — so we can repaint its icon when it finishes | |
| async function renderCard(skill) { | |
| shownSkillId = skill.id | |
| card.style.display = '' | |
| card.replaceChildren() | |
| const head = el('div', { style: 'display:flex;gap:12px;align-items:center' }) | |
| const icon = el('div', { style: 'position:relative;width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' }) | |
| if (skill.iconPending) icon.append(el('div', { class: 'ta-skill-spin', title: 'painting its scene…' })) // still painting → spinner, never a stale (reused-id) icon | |
| else { const b = await getSkillIcon(skill.id); if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})` } | |
| const meta = el('div', {}, [ | |
| el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name), | |
| el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`), | |
| ]) | |
| head.append(icon, meta) | |
| const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '') | |
| const note = skill.iconPending ? 'painting its scene… (already usable on the character sheet)' : '✓ Learned — view it on the hero’s character sheet.' | |
| const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, note) | |
| card.append(head, flav, saved) | |
| } | |
| // When the background image finishes, the hero's skill flips iconPending → false (onRosterChange). | |
| // If that skill is the one in the card, repaint it so the spinner swaps for the finished art. | |
| function repaintShownCard() { | |
| if (!shownSkillId || card.style.display === 'none') return | |
| const owner = listPersonas().find((p) => p.customSkills && p.customSkills[shownSkillId]) | |
| const sk = owner && owner.customSkills[shownSkillId] | |
| if (sk && !sk.iconPending) renderCard(sk) | |
| } | |
| let running = false | |
| async function forge() { | |
| if (running) return | |
| const p = getPersona(sel.value) | |
| if (!p) { status.textContent = 'Pick a hero first.'; return } | |
| if (!req.value.trim()) { status.textContent = 'Describe the skill you want.'; return } | |
| running = true; status.dataset.busy = '1'; btn.disabled = true; card.style.display = 'none' | |
| try { | |
| const res = await forgeSkillForHero(p.id, req.value, { onStatus: (s) => { status.textContent = s } }) | |
| if (!res.ok) { status.textContent = 'Forge failed: ' + res.error; return } | |
| await renderCard(res.skill) | |
| status.textContent = 'Done.' | |
| } catch (e) { | |
| status.textContent = 'Forge failed: ' + (e && e.message ? e.message : e) | |
| } finally { | |
| running = false; delete status.dataset.busy; btn.disabled = listPersonas().length === 0 | |
| } | |
| } | |
| btn.addEventListener('click', forge) | |
| onRosterChange(() => { refreshHeroes(); repaintShownCard() }) | |
| onCodingModelChange(refreshStatus) | |
| refreshHeroes(); refreshStatus() | |
| return { refresh: refreshHeroes } | |
| } | |