// Shared hero-creator — the recruit/stream/edit experience used by BOTH the Personas page // (personaPanel.js, which adds a barracks roster around it) and the Game page's "Create a hero" // modal (tiny.js). Renders the two-column `.persona-view` (class/seed/recruit controls on the left, // streamed name/about/quote + voice + portrait on the right), runs the whole generation pipeline // (local LLM → JSON persona, image engine → portrait, TTS → voice) and autosaves to the roster. // // const creator = mountHeroCreator(host, { extraControls, onSaved }) // creator.load(persona, { savedId }) // show an existing hero for editing // creator.current() // { persona, savedId } import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js' import { extractLivePersona } from '/web/personaStream.js' import { parsePersonaJson } from '/web/personaParse.js' import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js' import { createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive, activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange, ttsBackendLabel, } from '/web/tts.js' import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js' import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js' const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight'] const MAX_TOKENS = 200 // persona JSON + a voice line + a quote // Each class shows the idle pose of a fitting sprite (character slug → see // characters.json). The sheet's front-right row is animated as a tiny looping // icon beside the class name in the dropdown. export const CLASS_SLUG = { Warrior: 'true-heroes-iii-fighter', Ranger: 'true-heroes-iii-ranger', Monk: 'true-heroes-ii-bard', Assassin: 'true-heroes-iv-ninja-assassin', Mage: 'true-heroes-iii-wizard', Paladin: 'true-heroes-ii-paladin', Cleric: 'true-heroes-ii-cleric', Knight: 'rts-humans-knight', } const ICON_PX = 30 // on-screen size of the class icon box const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites 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.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 } // Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render // the front-right row (row 0) and step across the columns to loop the idle — no // canvas, just a sized background + the Web Animations API. export function animateIdleIcon(box, idleUrl, sizePx = ICON_PX) { box.getAnimations?.().forEach((a) => a.cancel()) box.style.backgroundImage = '' const img = new Image() img.onload = () => { const cell = (img.naturalHeight / 4) || img.naturalHeight const cols = Math.max(1, Math.round(img.naturalWidth / cell)) const rows = Math.max(1, Math.round(img.naturalHeight / cell)) const cellPx = sizePx * ICON_ZOOM // zoomed on-screen cell size (fills the box) const off = (cellPx - sizePx) / 2 // inset to centre the box on a cell box.style.backgroundImage = `url("${idleUrl}")` box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px` const y = `-${off}px` // row 0, vertically centred box.animate( [{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }], { duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` }, ) } img.src = idleUrl } // A class picker that mirrors a native