polats Claude Opus 4.8 (1M context) commited on
Commit
3ef6bd6
·
1 Parent(s): 0abf9a2

Game: hero creation + selection flow; pin the Space

Browse files

Shared hero creator (heroCreator.js) used by the Personas page and a new Game 'Create a Hero'/'Hero Details' modal: two-state layout (recruit ⇄ portrait panel), streamed details, generate portrait + quote + voice, barracks, fixed-size modal (no resize across states/streaming), and portrait repaint disabled unless the appearance changed.

Hero picker: cards show the saved portrait (or an animated class idle); picking opens a hero detail page (portrait/about/quote + Select) instead of spawning immediately; on Select the camera flies down to the spawn point and the hero drops in. Picker auto-refreshes on roster changes.

- web/comboBattler.js, web/mapSandbox.js: rebuilt bundles (flyTo + getSpawnWorld).
- README: pinned: true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: red
5
  colorTo: yellow
6
  sdk: docker
7
  app_port: 7860
8
- pinned: false
9
  license: mit
10
  short_description: Tiny Army — fighters write their own true legends
11
  ---
 
5
  colorTo: yellow
6
  sdk: docker
7
  app_port: 7860
8
+ pinned: true
9
  license: mit
10
  short_description: Tiny Army — fighters write their own true legends
11
  ---
web/comboBattler.js CHANGED
@@ -33,6 +33,7 @@ function createChunkedMap(pixi, host, config) {
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
 
36
  const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
37
  const chunks = /* @__PURE__ */ new Map();
38
  const macroChunks = /* @__PURE__ */ new Map();
@@ -257,6 +258,21 @@ function createChunkedMap(pixi, host, config) {
257
  function zoomBy(factor) {
258
  if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
259
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  function bindInput() {
261
  const canvas = app.canvas;
262
  const local = (cx, cy) => {
@@ -268,7 +284,7 @@ function createChunkedMap(pixi, host, config) {
268
  return [it.next().value, it.next().value];
269
  };
270
  handlers.down = (e) => {
271
- if (!enabled) return;
272
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
273
  canvas.setPointerCapture?.(e.pointerId);
274
  if (pointers.size === 1) {
@@ -308,7 +324,7 @@ function createChunkedMap(pixi, host, config) {
308
  } else if (pointers.size === 0) drag.on = false;
309
  };
310
  handlers.wheel = (e) => {
311
- if (!enabled) return;
312
  e.preventDefault();
313
  zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
314
  };
@@ -346,6 +362,23 @@ function createChunkedMap(pixi, host, config) {
346
  const PAN_SPEED = 6;
347
  function tick(ticker) {
348
  for (const fn of tickHooks) fn(ticker);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  if (!enabled) return;
350
  let dx = 0, dy = 0;
351
  for (const k of keys) {
@@ -490,7 +523,7 @@ function createChunkedMap(pixi, host, config) {
490
  ctx = null;
491
  texCache.clear();
492
  }
493
- return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE7 };
494
  }
495
 
496
  // ../auto-battler/src/engine/rng.js
@@ -5125,7 +5158,11 @@ function mountComboBattler(pixi, host, opts = {}) {
5125
  combatRoot = null;
5126
  listeners.clear();
5127
  }
5128
- const ctrl = { ready, selectHero, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
 
 
 
 
5129
  if (typeof window !== "undefined") {
5130
  window.__comboSnap = () => ctrl.getSnapshot();
5131
  window.__combo = ctrl;
 
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
36
+ let flyAnim = null;
37
  const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
38
  const chunks = /* @__PURE__ */ new Map();
39
  const macroChunks = /* @__PURE__ */ new Map();
 
258
  function zoomBy(factor) {
259
  if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
260
  }
261
+ function flyTo(wx, wy, z, ms = 800) {
262
+ return new Promise((resolve) => {
263
+ if (!app) {
264
+ resolve();
265
+ return;
266
+ }
267
+ if (flyAnim) {
268
+ const r = flyAnim.resolve;
269
+ flyAnim = null;
270
+ r && r();
271
+ }
272
+ const toZ = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, z));
273
+ flyAnim = { fromX: camera.x, fromY: camera.y, fromZ: zoom, toX: wx, toY: wy, toZ, t: 0, dur: Math.max(1, ms), resolve };
274
+ });
275
+ }
276
  function bindInput() {
277
  const canvas = app.canvas;
278
  const local = (cx, cy) => {
 
284
  return [it.next().value, it.next().value];
285
  };
286
  handlers.down = (e) => {
287
+ if (!enabled || flyAnim) return;
288
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
289
  canvas.setPointerCapture?.(e.pointerId);
290
  if (pointers.size === 1) {
 
324
  } else if (pointers.size === 0) drag.on = false;
325
  };
326
  handlers.wheel = (e) => {
327
+ if (!enabled || flyAnim) return;
328
  e.preventDefault();
329
  zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
330
  };
 
362
  const PAN_SPEED = 6;
363
  function tick(ticker) {
364
  for (const fn of tickHooks) fn(ticker);
365
+ if (flyAnim) {
366
+ flyAnim.t += ticker.deltaMS;
367
+ const p = Math.min(1, flyAnim.t / flyAnim.dur);
368
+ const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
369
+ camera.x = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
370
+ camera.y = flyAnim.fromY + (flyAnim.toY - flyAnim.fromY) * e;
371
+ zoom = flyAnim.fromZ + (flyAnim.toZ - flyAnim.fromZ) * e;
372
+ cameraDirty = true;
373
+ if (p >= 1) {
374
+ const r = flyAnim.resolve;
375
+ flyAnim = null;
376
+ r && r();
377
+ }
378
+ reconcile();
379
+ cameraDirty = false;
380
+ return;
381
+ }
382
  if (!enabled) return;
383
  let dx = 0, dy = 0;
384
  for (const k of keys) {
 
523
  ctx = null;
524
  texCache.clear();
525
  }
526
+ return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, flyTo, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE7 };
527
  }
528
 
529
  // ../auto-battler/src/engine/rng.js
 
5158
  combatRoot = null;
5159
  listeners.clear();
5160
  }
5161
+ function getSpawnWorld() {
5162
+ const b = map.getBounds();
5163
+ return b ? { x: b.x0 + 64 * TILE6, y: b.y1 - 64 * TILE6 } : { x: 0, y: 0 };
5164
+ }
5165
+ const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
5166
  if (typeof window !== "undefined") {
5167
  window.__comboSnap = () => ctrl.getSnapshot();
5168
  window.__combo = ctrl;
web/heroCreator.js ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared hero-creator — the recruit/stream/edit experience used by BOTH the Personas page
2
+ // (personaPanel.js, which adds a barracks roster around it) and the Game page's "Create a hero"
3
+ // modal (tiny.js). Renders the two-column `.persona-view` (class/seed/recruit controls on the left,
4
+ // streamed name/about/quote + voice + portrait on the right), runs the whole generation pipeline
5
+ // (local LLM → JSON persona, image engine → portrait, TTS → voice) and autosaves to the roster.
6
+ //
7
+ // const creator = mountHeroCreator(host, { extraControls, onSaved })
8
+ // creator.load(persona, { savedId }) // show an existing hero for editing
9
+ // creator.current() // { persona, savedId }
10
+ import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
11
+ import { extractLivePersona } from '/web/personaStream.js'
12
+ import { parsePersonaJson } from '/web/personaParse.js'
13
+ import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
14
+ import {
15
+ createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
16
+ activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange, ttsBackendLabel,
17
+ } from '/web/tts.js'
18
+ import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
19
+ import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
20
+
21
+ const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
22
+ const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
23
+
24
+ // Each class shows the idle pose of a fitting sprite (character slug → see
25
+ // characters.json). The sheet's front-right row is animated as a tiny looping
26
+ // icon beside the class name in the dropdown.
27
+ export const CLASS_SLUG = {
28
+ Warrior: 'true-heroes-iii-fighter',
29
+ Ranger: 'true-heroes-iii-ranger',
30
+ Monk: 'true-heroes-ii-bard',
31
+ Assassin: 'true-heroes-iv-ninja-assassin',
32
+ Mage: 'true-heroes-iii-wizard',
33
+ Paladin: 'true-heroes-ii-paladin',
34
+ Cleric: 'true-heroes-ii-cleric',
35
+ Knight: 'rts-humans-knight',
36
+ }
37
+ const ICON_PX = 30 // on-screen size of the class icon box
38
+ const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
39
+ const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
40
+
41
+ function el(tag, props = {}, kids = []) {
42
+ const n = document.createElement(tag)
43
+ for (const [k, v] of Object.entries(props)) {
44
+ if (k === 'class') n.className = v
45
+ else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
46
+ else if (v != null) n.setAttribute(k, v)
47
+ }
48
+ for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
49
+ return n
50
+ }
51
+
52
+ // Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
53
+ // the front-right row (row 0) and step across the columns to loop the idle — no
54
+ // canvas, just a sized background + the Web Animations API.
55
+ export function animateIdleIcon(box, idleUrl, sizePx = ICON_PX) {
56
+ box.getAnimations?.().forEach((a) => a.cancel())
57
+ box.style.backgroundImage = ''
58
+ const img = new Image()
59
+ img.onload = () => {
60
+ const cell = (img.naturalHeight / 4) || img.naturalHeight
61
+ const cols = Math.max(1, Math.round(img.naturalWidth / cell))
62
+ const rows = Math.max(1, Math.round(img.naturalHeight / cell))
63
+ const cellPx = sizePx * ICON_ZOOM // zoomed on-screen cell size (fills the box)
64
+ const off = (cellPx - sizePx) / 2 // inset to centre the box on a cell
65
+ box.style.backgroundImage = `url("${idleUrl}")`
66
+ box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
67
+ const y = `-${off}px` // row 0, vertically centred
68
+ box.animate(
69
+ [{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
70
+ { duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
71
+ )
72
+ }
73
+ img.src = idleUrl
74
+ }
75
+
76
+ // A class picker that mirrors a native <select> API (.value get/set, 'change'
77
+ // event) but renders an animated idle icon beside each class.
78
+ function makeClassDropdown(classes) {
79
+ const triggerIco = el('span', { class: 'persona-class-ico' })
80
+ const triggerLabel = el('span', { class: 'persona-classdrop-label' })
81
+ const trigger = el('button', { class: 'persona-input persona-classdrop-trigger', type: 'button' },
82
+ [triggerIco, triggerLabel, el('span', { class: 'persona-classdrop-chev' }, '▾')])
83
+ const menu = el('div', { class: 'persona-classdrop-menu' })
84
+ const root = el('div', { class: 'persona-classdrop' }, [trigger, menu])
85
+
86
+ let value = classes[0]
87
+ let icons = {} // class → idle sheet URL (filled by setIcons)
88
+ const optIco = {} // class → menu icon span
89
+
90
+ const items = classes.map((c) => {
91
+ const ico = el('span', { class: 'persona-class-ico' }); optIco[c] = ico
92
+ const it = el('button', { class: 'persona-classdrop-opt', type: 'button' }, [ico, el('span', {}, c)])
93
+ it.addEventListener('click', () => { set(c); close() })
94
+ return it
95
+ })
96
+ menu.append(...items)
97
+
98
+ function set(c) {
99
+ if (!classes.includes(c)) return
100
+ const changed = c !== value
101
+ value = c
102
+ triggerLabel.textContent = c
103
+ items.forEach((it, i) => it.classList.toggle('sel', classes[i] === c))
104
+ if (icons[c]) animateIdleIcon(triggerIco, icons[c])
105
+ if (changed) root.dispatchEvent(new Event('change'))
106
+ }
107
+ const close = () => root.classList.remove('open')
108
+ trigger.addEventListener('click', (e) => { e.stopPropagation(); root.classList.toggle('open') })
109
+ document.addEventListener('click', (e) => { if (!root.contains(e.target)) close() })
110
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
111
+
112
+ root.setIcons = (map) => {
113
+ icons = map
114
+ for (const c of classes) if (map[c]) animateIdleIcon(optIco[c], map[c])
115
+ if (map[value]) animateIdleIcon(triggerIco, map[value])
116
+ }
117
+ Object.defineProperty(root, 'value', { get: () => value, set: (v) => set(v) })
118
+ triggerLabel.textContent = value
119
+ items.forEach((it, i) => it.classList.toggle('sel', classes[i] === value))
120
+ return root
121
+ }
122
+
123
+ // Resolve each class's idle sheet via characters.json and light up the dropdown.
124
+ async function loadClassIcons(dropdown) {
125
+ try {
126
+ const d = await fetch('/sprites/characters.json').then((r) => r.json())
127
+ const bySlug = {}
128
+ for (const p of d.packs || []) for (const c of p.characters || []) bySlug[c.slug] = c
129
+ const map = {}
130
+ for (const [cls, slug] of Object.entries(CLASS_SLUG)) {
131
+ const idle = bySlug[slug]?.idle
132
+ if (idle) map[cls] = spriteUrl(idle)
133
+ }
134
+ dropdown.setIcons(map)
135
+ } catch { /* no icons — the dropdown still works with labels only */ }
136
+ }
137
+
138
+ // Mount the creator into `host`. `opts.extraControls` (DOM) is appended into the controls aside
139
+ // (the Personas page passes its barracks roster here). `opts.onSaved(rec)` fires after every
140
+ // autosave (generate + inline edits + portrait/voice), so the host can refresh a roster, enable a
141
+ // "Save & Play" button, etc. Returns a controller (load / current / reset / stop).
142
+ export function mountHeroCreator(host, opts = {}) {
143
+ const DEFAULT_STATUS = 'Runs on your device — no cloud.'
144
+ const sel = makeClassDropdown(CLASSES)
145
+ loadClassIcons(sel)
146
+ const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
147
+ const stats = el('div', { class: 'persona-stats' })
148
+ const status = el('div', { class: 'persona-status' }, DEFAULT_STATUS)
149
+ const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
150
+
151
+ const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
152
+ const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
153
+ const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
154
+ const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
155
+ const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
156
+ const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
157
+ [el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
158
+ const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
159
+ const voiceStatus = el('span', { class: 'persona-act-status' }) // "generating voice via …" beside ▶
160
+ const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
161
+ const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
162
+ const portraitStatus = el('span', { class: 'persona-act-status' }) // "painting via …" beside 🎨
163
+ const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
164
+ const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
165
+ // The general status line + token rate live INSIDE the debug section (only per-action voice/portrait
166
+ // notes show beside their buttons). No copy button.
167
+ const thinkEl = el('pre', { class: 'persona-think' })
168
+ const thinkWrap = el('details', { class: 'persona-think-wrap' },
169
+ [el('summary', {}, 'model output / debug (raw)'), status, stats, thinkEl])
170
+
171
+ // Plain section header (top line + small heading) with an optional right-side action.
172
+ const secHead = (title, action) =>
173
+ el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), action || el('span')])
174
+ // Header whose action is a create button with an inline status note to its RIGHT (full model name).
175
+ const actionHead = (title, button, note) =>
176
+ el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), el('div', { class: 'persona-sec-action' }, [button, note])])
177
+
178
+ const backBtn = el('button', { class: 'persona-back', type: 'button' }, '← Back')
179
+ // Optional barracks roster (saved heroes) — always visible in the left column when enabled.
180
+ const rosterEl = opts.showBarracks ? el('div', { class: 'persona-roster' }) : null
181
+ const barracksEl = opts.showBarracks
182
+ ? el('div', { class: 'persona-barracks' }, [el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl])
183
+ : null
184
+
185
+ // Left column has two states. STATE A (recruit): class + seed + Recruit. STATE B (a hero exists): a
186
+ // portrait panel — placeholder/image + 🎨 paint button & badge + the editable appearance prompt. The
187
+ // barracks (if any) and the model-output debug are shown in BOTH states; the debug is anchored bottom.
188
+ const recruitBox = el('div', { class: 'persona-recruit-box' }, [
189
+ el('label', { class: 'persona-label' }, 'Class'), sel,
190
+ el('label', { class: 'persona-label' }, 'Seed'), seed,
191
+ btn,
192
+ ])
193
+ const portraitBox = el('div', { class: 'persona-portrait-panel' }, [
194
+ actionHead('Portrait', portraitBtn, portraitStatus),
195
+ portraitWrap,
196
+ appearanceEl,
197
+ ])
198
+ const controls = el('aside', { class: 'persona-controls' }, [
199
+ recruitBox, portraitBox, barracksEl, thinkWrap,
200
+ ])
201
+ const emptyEl = el('div', { class: 'persona-empty' }, opts.emptyText || 'Every legend starts here — pick a class and recruit your hero.')
202
+ const bodyEl = el('div', { class: 'persona-body' }, [
203
+ nameEl,
204
+ secHead('About'), aboutEl,
205
+ actionHead('Quote', playBtn, voiceStatus), quoteEl,
206
+ secHead('Voice design'), voiceEl, voicePickRow,
207
+ ])
208
+ const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl])
209
+ const view = el('div', { class: 'persona-view' }, [controls, result])
210
+ host.appendChild(view)
211
+
212
+ // Back button: in the host-provided footer slot (the Game modal) or, by default, at the top of the
213
+ // portrait panel. It RESETS the creator and returns to the recruit state; only shown in STATE B.
214
+ if (opts.backSlot) opts.backSlot.prepend(backBtn)
215
+ else portraitBox.prepend(backBtn)
216
+ backBtn.addEventListener('click', () => resetAll())
217
+
218
+ function setLeftState(s) {
219
+ const portrait = s === 'portrait'
220
+ recruitBox.style.display = portrait ? 'none' : ''
221
+ portraitBox.style.display = portrait ? '' : 'none'
222
+ backBtn.style.display = portrait ? '' : 'none'
223
+ if (barracksEl) barracksEl.style.display = portrait ? 'none' : '' // barracks only in the recruit state
224
+ try { opts.onState?.(s) } catch { /* host hook */ }
225
+ }
226
+ setLeftState('recruit')
227
+
228
+ let lastPersona = null // the persona currently shown
229
+ let savedId = null // its roster id (set the moment it's shown — always saved)
230
+ let hasVoice = false // a cached voice file exists for this persona
231
+ let hasPortrait = false // a cached portrait exists for this persona
232
+ let portraitBusy = false
233
+ let portraitUrl = null // object URL for the shown image (revoked on replace)
234
+ let working = false
235
+ let busy = false
236
+ let playing = false // audio is currently sounding (▶ becomes ⏹)
237
+
238
+ const fireSaved = (rec) => { try { opts.onSaved?.(rec, { persona: lastPersona, savedId }) } catch { /* host hook */ } }
239
+
240
+ function setPlaying(on) {
241
+ playing = on
242
+ playBtn.classList.toggle('playing', on)
243
+ playBtn.textContent = on ? '⏹' : '▶'
244
+ if (on) playBtn.title = 'Stop'
245
+ else updateVoiceUI()
246
+ }
247
+ function stopVoice() { stopVoiceLive(); setPlaying(false) }
248
+ async function playBuf(arrayBuffer) {
249
+ setPlaying(true)
250
+ try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
251
+ finally { setPlaying(false) }
252
+ }
253
+
254
+ function refreshVisibility() {
255
+ const show = !!lastPersona || busy
256
+ bodyEl.style.display = show ? '' : 'none'
257
+ emptyEl.style.display = show ? 'none' : ''
258
+ }
259
+ refreshVisibility()
260
+
261
+ const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
262
+ const isDesign = () => activeEngineIsDesign()
263
+ const isNative = () => activeEngineIsNative()
264
+ const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
265
+ const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
266
+ const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
267
+ const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
268
+ function updateVoiceUI() {
269
+ const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
270
+ playBtn.classList.toggle('badged', needs)
271
+ if (playing) return
272
+ playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
273
+ }
274
+
275
+ function refreshVoiceMode() {
276
+ const design = isDesign()
277
+ voiceEl.contentEditable = design ? 'true' : 'false'
278
+ voiceEl.classList.toggle('readonly', !design)
279
+ voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
280
+ voicePickRow.style.display = design ? 'none' : ''
281
+ if (!design) {
282
+ const voices = activeVoices()
283
+ voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
284
+ let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
285
+ if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
286
+ voicePickEl.value = cur
287
+ if (lastPersona) lastPersona.voiceId = cur
288
+ }
289
+ }
290
+ voicePickEl.addEventListener('change', () => {
291
+ if (!lastPersona) return
292
+ lastPersona.voiceId = voicePickEl.value
293
+ autosave(); updateVoiceUI()
294
+ })
295
+ onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
296
+
297
+ function autosave() {
298
+ if (!lastPersona) return
299
+ const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
300
+ savedId = rec.id
301
+ fireSaved(rec)
302
+ }
303
+
304
+ function editable(elm, field, { single = false } = {}) {
305
+ elm.contentEditable = 'true'
306
+ elm.spellcheck = false
307
+ if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
308
+ if (field === 'quote' || field === 'voice') {
309
+ elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
310
+ }
311
+ elm.addEventListener('blur', () => {
312
+ if (!lastPersona) return
313
+ lastPersona[field] = elm.textContent.trim()
314
+ autosave(); updateVoiceUI()
315
+ })
316
+ }
317
+ editable(nameEl, 'name', { single: true })
318
+ editable(aboutEl, 'about')
319
+ editable(quoteEl, 'quote', { single: true })
320
+ editable(voiceEl, 'voice')
321
+
322
+ // ── Portrait ──────────────────────────────────────────────────────────────
323
+ const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
324
+ const buildAppearance = (p) => [
325
+ [p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
326
+ (p.about || '').trim(),
327
+ ].filter(Boolean).join('. ')
328
+ const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
329
+ const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
330
+
331
+ function setPortrait(blob) {
332
+ if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
333
+ portraitUrl = blob ? URL.createObjectURL(blob) : null
334
+ portraitImg.src = portraitUrl || ''
335
+ portraitWrap.classList.toggle('has-img', !!blob)
336
+ }
337
+ function updatePortraitUI() {
338
+ // Paintable when there's no portrait yet, or the appearance changed since the last one. When the
339
+ // portrait already matches the definition, repainting is disabled (nothing to redo).
340
+ const paintable = !!lastPersona && (!hasPortrait || portraitDirty())
341
+ portraitBtn.classList.toggle('badged', paintable)
342
+ portraitBtn.disabled = portraitBusy || (!!lastPersona && !paintable)
343
+ portraitBtn.title = !lastPersona ? 'Paint portrait' : (!hasPortrait ? 'Paint portrait' : (portraitDirty() ? 'Repaint portrait' : 'Portrait is up to date'))
344
+ }
345
+ appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
346
+ appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
347
+ appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
348
+
349
+ async function makePortrait() {
350
+ if (portraitBusy || !lastPersona) return
351
+ if (hasPortrait && !portraitDirty()) return // no change to the appearance — don't repaint
352
+ autosave() // ensure an id to key the image
353
+ const appearance = appearanceFor(lastPersona)
354
+ portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
355
+ try {
356
+ if (imageNeedsDownload()) {
357
+ portraitStatus.textContent = 'loading model…'
358
+ await ensureImage((f) => { portraitStatus.textContent = `downloading model… ${Math.round(f * 100)}%` })
359
+ }
360
+ portraitStatus.textContent = `painting via ${imageBackendLabel()}…`
361
+ const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
362
+ await putPortrait(savedId, blob)
363
+ lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
364
+ hasPortrait = true; setPortrait(blob); autosave()
365
+ portraitStatus.textContent = ''
366
+ } catch (e) { portraitStatus.textContent = `failed: ${e.message || e}` }
367
+ finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
368
+ }
369
+ portraitBtn.addEventListener('click', makePortrait)
370
+
371
+ async function showPersona(p, o = {}) {
372
+ stopVoice() // picking another hero cuts the current voice
373
+ lastPersona = { ...p }
374
+ savedId = o.savedId || null
375
+ nameEl.textContent = p.name || ''
376
+ aboutEl.textContent = p.about || ''
377
+ quoteEl.textContent = p.quote || ''
378
+ voiceEl.textContent = p.voice || ''
379
+ appearanceEl.textContent = p.appearance || buildAppearance(p)
380
+ hasVoice = savedId ? !!(await getAudio(savedId)) : false
381
+ let pblob = null
382
+ hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
383
+ setPortrait(pblob)
384
+ refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
385
+ setLeftState('portrait') // a hero exists → show the portrait panel in place of class/recruit
386
+ }
387
+
388
+ async function play() {
389
+ if (playing) { stopVoice(); return }
390
+ if (working || !lastPersona) return
391
+ const line = lineFor(lastPersona)
392
+
393
+ if (isNative()) {
394
+ setPlaying(true)
395
+ try { await speakVoiceLive(lastPersona.voiceId || '', line) }
396
+ catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
397
+ finally { setPlaying(false) }
398
+ return
399
+ }
400
+
401
+ if (hasVoice && !isDirty()) {
402
+ const blob = savedId ? await getAudio(savedId) : null
403
+ if (blob) { await playBuf(await blob.arrayBuffer()); return }
404
+ hasVoice = false
405
+ }
406
+ if (isDesign() && !lastPersona.voice) { voiceStatus.textContent = 'add a voice design first'; return }
407
+ autosave() // ensure an id to key the audio
408
+
409
+ const design = isDesign()
410
+ const reclone = design && hasVoice && !designChanged()
411
+ working = true; playBtn.classList.add('busy'); playBtn.disabled = true
412
+ const verb = reclone ? 'updating' : (design ? 'designing' : 'generating')
413
+ voiceStatus.textContent = `${verb} voice via ${ttsBackendLabel()}…`
414
+ let wav = null
415
+ try {
416
+ if (design && reclone) {
417
+ const blob = await getAudio(savedId)
418
+ wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
419
+ } else if (design) {
420
+ wav = await createVoiceWav(lastPersona.voice, line)
421
+ } else {
422
+ wav = await synthVoiceWav(lastPersona.voiceId || '', line)
423
+ }
424
+ await putAudio(savedId, new Blob([wav], { type: 'audio/wav' }))
425
+ lastPersona.voiceQuote = line
426
+ lastPersona.voiceDesignUsed = lastPersona.voice || ''
427
+ lastPersona.voiceIdUsed = lastPersona.voiceId || ''
428
+ hasVoice = true; autosave()
429
+ voiceStatus.textContent = ''
430
+ } catch (e) { voiceStatus.textContent = `failed: ${e.message || e}` }
431
+ finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
432
+ if (wav) await playBuf(wav.slice(0))
433
+ }
434
+ playBtn.addEventListener('click', play)
435
+
436
+ try {
437
+ new IntersectionObserver((entries) => {
438
+ for (const e of entries) {
439
+ if (!e.isIntersecting) { if (playing) stopVoice() }
440
+ else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() }
441
+ }
442
+ }).observe(host)
443
+ } catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
444
+
445
+ // ← Back: wipe every field and return to a blank recruit state.
446
+ function resetAll() {
447
+ stopVoice()
448
+ lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
449
+ nameEl.textContent = ''; aboutEl.textContent = ''; quoteEl.textContent = ''
450
+ voiceEl.textContent = ''; appearanceEl.textContent = ''
451
+ setPortrait(null)
452
+ voiceStatus.textContent = ''; portraitStatus.textContent = ''
453
+ stats.textContent = ''; status.textContent = DEFAULT_STATUS; thinkEl.textContent = ''; thinkWrap.open = false
454
+ updateVoiceUI(); updatePortraitUI(); refreshVisibility(); setLeftState('recruit')
455
+ }
456
+
457
+ // ── Barracks (saved heroes) — clicking one loads it for editing; always visible when enabled. ──
458
+ function renderRoster(personas) {
459
+ if (!rosterEl) return
460
+ if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
461
+ rosterEl.replaceChildren(...personas.map((p) =>
462
+ el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
463
+ el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
464
+ `${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
465
+ el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
466
+ ])))
467
+ }
468
+ if (opts.showBarracks) { renderRoster(listPersonas()); onRosterChange(renderRoster) }
469
+
470
+ async function generate() {
471
+ if (busy) return
472
+ busy = true; btn.disabled = true
473
+ setLeftState('portrait') // swap the left column to the portrait panel the moment we start
474
+ refreshVisibility()
475
+ if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
476
+ nameEl.textContent = '…'; aboutEl.textContent = ''
477
+ quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
478
+ lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
479
+ stopVoice(); setPortrait(null); voiceStatus.textContent = ''; portraitStatus.textContent = ''; updateVoiceUI(); updatePortraitUI()
480
+ thinkEl.textContent = ''; stats.textContent = '' // debug stays COLLAPSED — status/tokens update inside it
481
+ let acc = ''
482
+ try {
483
+ status.textContent = `loading ${currentModel().label}…`
484
+ await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
485
+ status.textContent = `writing on your device with ${currentModel().label}…`
486
+ await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
487
+ maxTokens: MAX_TOKENS,
488
+ onToken: (piece) => {
489
+ acc += piece
490
+ thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
491
+ const live = extractLivePersona(stripThink(acc))
492
+ if (live.name) nameEl.textContent = live.name
493
+ if (live.about) aboutEl.textContent = live.about
494
+ },
495
+ onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
496
+ })
497
+ try {
498
+ const p = parsePersonaJson(stripThinkFinal(acc))
499
+ await showPersona(p)
500
+ autosave() // generated personas are saved immediately (no Save button)
501
+ status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
502
+ } catch (e) {
503
+ status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e})`
504
+ setLeftState('recruit') // back to recruit so they can retry
505
+ }
506
+ } catch (e) {
507
+ status.textContent = `couldn't run the local model: ${e.message || e}`
508
+ setLeftState('recruit')
509
+ } finally { busy = false; btn.disabled = false; refreshVisibility() }
510
+ }
511
+ btn.addEventListener('click', generate)
512
+
513
+ return {
514
+ root: view,
515
+ load: (p, o = {}) => showPersona(p, o),
516
+ current: () => ({ persona: lastPersona, savedId }),
517
+ reset: resetAll,
518
+ stop: stopVoice,
519
+ }
520
+ }
web/mapSandbox.js CHANGED
@@ -33,6 +33,7 @@ function createChunkedMap(pixi, host, config) {
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
 
36
  const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
37
  const chunks = /* @__PURE__ */ new Map();
38
  const macroChunks = /* @__PURE__ */ new Map();
@@ -257,6 +258,21 @@ function createChunkedMap(pixi, host, config) {
257
  function zoomBy(factor) {
258
  if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
259
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  function bindInput() {
261
  const canvas = app.canvas;
262
  const local = (cx, cy) => {
@@ -268,7 +284,7 @@ function createChunkedMap(pixi, host, config) {
268
  return [it.next().value, it.next().value];
269
  };
270
  handlers.down = (e) => {
271
- if (!enabled) return;
272
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
273
  canvas.setPointerCapture?.(e.pointerId);
274
  if (pointers.size === 1) {
@@ -308,7 +324,7 @@ function createChunkedMap(pixi, host, config) {
308
  } else if (pointers.size === 0) drag.on = false;
309
  };
310
  handlers.wheel = (e) => {
311
- if (!enabled) return;
312
  e.preventDefault();
313
  zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
314
  };
@@ -346,6 +362,23 @@ function createChunkedMap(pixi, host, config) {
346
  const PAN_SPEED = 6;
347
  function tick(ticker) {
348
  for (const fn of tickHooks) fn(ticker);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  if (!enabled) return;
350
  let dx = 0, dy = 0;
351
  for (const k of keys) {
@@ -490,7 +523,7 @@ function createChunkedMap(pixi, host, config) {
490
  ctx = null;
491
  texCache.clear();
492
  }
493
- return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
494
  }
495
 
496
  // ../auto-battler/src/engine/rng.js
 
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
36
+ let flyAnim = null;
37
  const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
38
  const chunks = /* @__PURE__ */ new Map();
39
  const macroChunks = /* @__PURE__ */ new Map();
 
258
  function zoomBy(factor) {
259
  if (app) zoomAt(factor, app.screen.width / 2, app.screen.height / 2);
260
  }
261
+ function flyTo(wx, wy, z, ms = 800) {
262
+ return new Promise((resolve) => {
263
+ if (!app) {
264
+ resolve();
265
+ return;
266
+ }
267
+ if (flyAnim) {
268
+ const r = flyAnim.resolve;
269
+ flyAnim = null;
270
+ r && r();
271
+ }
272
+ const toZ = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, z));
273
+ flyAnim = { fromX: camera.x, fromY: camera.y, fromZ: zoom, toX: wx, toY: wy, toZ, t: 0, dur: Math.max(1, ms), resolve };
274
+ });
275
+ }
276
  function bindInput() {
277
  const canvas = app.canvas;
278
  const local = (cx, cy) => {
 
284
  return [it.next().value, it.next().value];
285
  };
286
  handlers.down = (e) => {
287
+ if (!enabled || flyAnim) return;
288
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
289
  canvas.setPointerCapture?.(e.pointerId);
290
  if (pointers.size === 1) {
 
324
  } else if (pointers.size === 0) drag.on = false;
325
  };
326
  handlers.wheel = (e) => {
327
+ if (!enabled || flyAnim) return;
328
  e.preventDefault();
329
  zoomAt(e.deltaY < 0 ? Z_STEP : 1 / Z_STEP, ...local(e.clientX, e.clientY));
330
  };
 
362
  const PAN_SPEED = 6;
363
  function tick(ticker) {
364
  for (const fn of tickHooks) fn(ticker);
365
+ if (flyAnim) {
366
+ flyAnim.t += ticker.deltaMS;
367
+ const p = Math.min(1, flyAnim.t / flyAnim.dur);
368
+ const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
369
+ camera.x = flyAnim.fromX + (flyAnim.toX - flyAnim.fromX) * e;
370
+ camera.y = flyAnim.fromY + (flyAnim.toY - flyAnim.fromY) * e;
371
+ zoom = flyAnim.fromZ + (flyAnim.toZ - flyAnim.fromZ) * e;
372
+ cameraDirty = true;
373
+ if (p >= 1) {
374
+ const r = flyAnim.resolve;
375
+ flyAnim = null;
376
+ r && r();
377
+ }
378
+ reconcile();
379
+ cameraDirty = false;
380
+ return;
381
+ }
382
  if (!enabled) return;
383
  let dx = 0, dy = 0;
384
  for (const k of keys) {
 
523
  ctx = null;
524
  texCache.clear();
525
  }
526
+ return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, flyTo, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
527
  }
528
 
529
  // ../auto-battler/src/engine/rng.js
web/personaPanel.js CHANGED
@@ -1,512 +1,11 @@
1
- // Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a hero
2
- // (name/about/traits + a voice design + a spoken quote), lets you CREATE a voice file of
3
- // them saying their quote (Qwen3-TTS) and REPLAY it, edit every field inline (auto-saved),
4
- // and keeps everyone in a local-first barracks roster (personaStore) so they persist for
5
- // returning visitors. Modeled on woid's agent store.
6
- import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
7
- import { extractLivePersona } from '/web/personaStream.js'
8
- import { parsePersonaJson } from '/web/personaParse.js'
9
- import { getPersonaSystem, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
10
- import {
11
- createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
12
- activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
13
- } from '/web/tts.js'
14
- import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
15
- import { generatePortrait, imageBackendLabel, imageNeedsDownload, ensureImage } from '/web/imagen.js'
16
 
17
- const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
18
- const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
19
-
20
- // Each class shows the idle pose of a fitting sprite (character slug → see
21
- // characters.json). The sheet's front-right row is animated as a tiny looping
22
- // icon beside the class name in the dropdown.
23
- export const CLASS_SLUG = {
24
- Warrior: 'true-heroes-iii-fighter',
25
- Ranger: 'true-heroes-iii-ranger',
26
- Monk: 'true-heroes-ii-bard',
27
- Assassin: 'true-heroes-iv-ninja-assassin',
28
- Mage: 'true-heroes-iii-wizard',
29
- Paladin: 'true-heroes-ii-paladin',
30
- Cleric: 'true-heroes-ii-cleric',
31
- Knight: 'rts-humans-knight',
32
- }
33
- const ICON_PX = 30 // on-screen size of the class icon box
34
- const ICON_ZOOM = 2.4 // sheets pad the character inside each cell — zoom in so it fills the box
35
- const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/') // sheets serve at /sprites
36
-
37
- function el(tag, props = {}, kids = []) {
38
- const n = document.createElement(tag)
39
- for (const [k, v] of Object.entries(props)) {
40
- if (k === 'class') n.className = v
41
- else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
42
- else if (v != null) n.setAttribute(k, v)
43
- }
44
- for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
45
- return n
46
- }
47
-
48
- // Idle sheets are 4 rows (facings) × N frame columns; cell = height/4. We render
49
- // the front-right row (row 0) and step across the columns to loop the idle — no
50
- // canvas, just a sized background + the Web Animations API. Each cell is scaled to
51
- // ICON_ZOOM × the box and the box is centred on it, so the padded-in character
52
- // fills the icon instead of floating tiny in a sea of transparent cell.
53
- function animateIdleIcon(box, idleUrl) {
54
- box.getAnimations?.().forEach((a) => a.cancel())
55
- box.style.backgroundImage = ''
56
- const img = new Image()
57
- img.onload = () => {
58
- const cell = (img.naturalHeight / 4) || img.naturalHeight
59
- const cols = Math.max(1, Math.round(img.naturalWidth / cell))
60
- const rows = Math.max(1, Math.round(img.naturalHeight / cell))
61
- const cellPx = ICON_PX * ICON_ZOOM // zoomed on-screen cell size
62
- const off = (cellPx - ICON_PX) / 2 // inset to centre the box on a cell
63
- box.style.backgroundImage = `url("${idleUrl}")`
64
- box.style.backgroundSize = `${cols * cellPx}px ${rows * cellPx}px`
65
- const y = `-${off}px` // row 0, vertically centred
66
- box.animate(
67
- [{ backgroundPosition: `-${off}px ${y}` }, { backgroundPosition: `-${cols * cellPx + off}px ${y}` }],
68
- { duration: cols * 110, iterations: Infinity, easing: `steps(${cols})` },
69
- )
70
- }
71
- img.src = idleUrl
72
- }
73
-
74
- // A class picker that mirrors a native <select> API (.value get/set, 'change'
75
- // event) but renders an animated idle icon beside each class. Icons attach once
76
- // characters.json resolves (root.setIcons), so the menu is usable immediately.
77
- function makeClassDropdown(classes) {
78
- const triggerIco = el('span', { class: 'persona-class-ico' })
79
- const triggerLabel = el('span', { class: 'persona-classdrop-label' })
80
- const trigger = el('button', { class: 'persona-input persona-classdrop-trigger', type: 'button' },
81
- [triggerIco, triggerLabel, el('span', { class: 'persona-classdrop-chev' }, '▾')])
82
- const menu = el('div', { class: 'persona-classdrop-menu' })
83
- const root = el('div', { class: 'persona-classdrop' }, [trigger, menu])
84
-
85
- let value = classes[0]
86
- let icons = {} // class → idle sheet URL (filled by setIcons)
87
- const optIco = {} // class → menu icon span
88
-
89
- const items = classes.map((c) => {
90
- const ico = el('span', { class: 'persona-class-ico' }); optIco[c] = ico
91
- const it = el('button', { class: 'persona-classdrop-opt', type: 'button' }, [ico, el('span', {}, c)])
92
- it.addEventListener('click', () => { set(c); close() })
93
- return it
94
- })
95
- menu.append(...items)
96
-
97
- function set(c) {
98
- if (!classes.includes(c)) return
99
- const changed = c !== value
100
- value = c
101
- triggerLabel.textContent = c
102
- items.forEach((it, i) => it.classList.toggle('sel', classes[i] === c))
103
- if (icons[c]) animateIdleIcon(triggerIco, icons[c])
104
- if (changed) root.dispatchEvent(new Event('change'))
105
- }
106
- const close = () => root.classList.remove('open')
107
- trigger.addEventListener('click', (e) => { e.stopPropagation(); root.classList.toggle('open') })
108
- document.addEventListener('click', (e) => { if (!root.contains(e.target)) close() })
109
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
110
-
111
- root.setIcons = (map) => {
112
- icons = map
113
- for (const c of classes) if (map[c]) animateIdleIcon(optIco[c], map[c])
114
- if (map[value]) animateIdleIcon(triggerIco, map[value])
115
- }
116
- Object.defineProperty(root, 'value', { get: () => value, set: (v) => set(v) })
117
- triggerLabel.textContent = value
118
- items.forEach((it, i) => it.classList.toggle('sel', classes[i] === value))
119
- return root
120
- }
121
-
122
- // Resolve each class's idle sheet via characters.json and light up the dropdown.
123
- async function loadClassIcons(dropdown) {
124
- try {
125
- const d = await fetch('/sprites/characters.json').then((r) => r.json())
126
- const bySlug = {}
127
- for (const p of d.packs || []) for (const c of p.characters || []) bySlug[c.slug] = c
128
- const map = {}
129
- for (const [cls, slug] of Object.entries(CLASS_SLUG)) {
130
- const idle = bySlug[slug]?.idle
131
- if (idle) map[cls] = spriteUrl(idle)
132
- }
133
- dropdown.setIcons(map)
134
- } catch { /* no icons — the dropdown still works with labels only */ }
135
- }
136
 
137
  export function mountPersonaPanel(host) {
138
- const sel = makeClassDropdown(CLASSES)
139
- loadClassIcons(sel)
140
- const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
141
- const stats = el('div', { class: 'persona-stats' })
142
- const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
143
- const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
144
- const rosterEl = el('div', { class: 'persona-roster' })
145
-
146
- const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
147
- const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
148
- const quoteEl = el('blockquote', { class: 'persona-quote persona-edit', 'data-ph': 'A line they say…' })
149
- const voiceEl = el('div', { class: 'persona-voice-desc persona-edit', 'data-ph': 'How they sound…' })
150
- // Fixed-voice providers (Kokoro/Kitten/Web Speech) don't design from text — pick a
151
- // named voice here instead. Hidden when the provider is Qwen3-TTS (Voice Design).
152
- const voicePickEl = el('select', { class: 'persona-input persona-voice-pick' })
153
- const voicePickRow = el('div', { class: 'persona-voice-pick-row' },
154
- [el('label', { class: 'persona-label' }, 'Voice'), voicePickEl])
155
- // ▶ play sits on the Quote heading and does everything: it (re)creates the voice when
156
- // needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
157
- // the quote/voice was edited since the last one was made.
158
- const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
159
- // Portrait: an editable appearance prompt + a 🎨 button that paints it (Z-Image/FLUX),
160
- // cached per hero. Badge pulses when there's no portrait yet or the appearance changed.
161
- const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
162
- const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
163
- const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
164
- const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
165
- const thinkEl = el('pre', { class: 'persona-think' })
166
- const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
167
- const thinkWrap = el('details', { class: 'persona-think-wrap' },
168
- [el('summary', {}, 'model output / debug (raw)'), copyBtn, thinkEl])
169
-
170
- // A section header: a top line + a small red heading, with an optional action on the right.
171
- const secHead = (title, action) =>
172
- el('div', { class: 'persona-sec' }, [el('div', { class: 'persona-sec-title' }, title), action || el('span')])
173
-
174
- const controls = el('aside', { class: 'persona-controls' }, [
175
- el('label', { class: 'persona-label' }, 'Class'), sel,
176
- el('label', { class: 'persona-label' }, 'Seed'), seed,
177
- btn, stats, status,
178
- el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
179
- ])
180
- const emptyEl = el('div', { class: 'persona-empty' }, 'Recruit a hero, or pick one from the barracks.')
181
- const bodyEl = el('div', { class: 'persona-body' }, [
182
- nameEl,
183
- secHead('About'), aboutEl,
184
- secHead('Quote', playBtn), quoteEl,
185
- secHead('Voice design'), voiceEl, voicePickRow,
186
- secHead('Portrait', portraitBtn), appearanceEl, portraitWrap,
187
- ])
188
- const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
189
- host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
190
-
191
- let lastPersona = null // the persona currently shown
192
- let savedId = null // its roster id (set the moment it's shown — always saved)
193
- let hasVoice = false // a cached voice file exists for this persona
194
- let hasPortrait = false // a cached portrait exists for this persona
195
- let portraitBusy = false
196
- let portraitUrl = null // object URL for the shown image (revoked on replace)
197
- let working = false
198
- let busy = false
199
- let playing = false // audio is currently sounding (▶ becomes ⏹)
200
-
201
- // ▶ ⇄ ⏹: reflect playback state on the button so a second click stops it.
202
- function setPlaying(on) {
203
- playing = on
204
- playBtn.classList.toggle('playing', on)
205
- playBtn.textContent = on ? '⏹' : '▶'
206
- if (on) playBtn.title = 'Stop'
207
- else updateVoiceUI()
208
- }
209
- // Stop any sounding voice and reset the button (used on toggle, nav-away, new pick).
210
- function stopVoice() { stopVoiceLive(); setPlaying(false) }
211
- // Play a WAV buffer with the button reflecting play→stop→idle, even if it's cut short.
212
- async function playBuf(arrayBuffer) {
213
- setPlaying(true)
214
- try { await playWav(arrayBuffer) } catch { /* autoplay blocked / cut short */ }
215
- finally { setPlaying(false) }
216
- }
217
-
218
- // Hide the hero fields until a hero is generated or picked from the barracks.
219
- function refreshVisibility() {
220
- const show = !!lastPersona || busy
221
- bodyEl.style.display = show ? '' : 'none'
222
- emptyEl.style.display = show ? 'none' : ''
223
- }
224
- refreshVisibility()
225
-
226
- // The line the voice actually says (quote, else about, else a fallback).
227
- const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
228
- // Which "voice" identity drives the active provider, and what the cached file used.
229
- // Qwen3-TTS → the free-form DESIGN text; others → the picked named voice id.
230
- const isDesign = () => activeEngineIsDesign()
231
- const isNative = () => activeEngineIsNative()
232
- const voiceNow = () => (isDesign() ? (lastPersona?.voice || '') : (lastPersona?.voiceId || ''))
233
- const voiceUsed = () => (isDesign() ? (lastPersona?.voiceDesignUsed || '') : (lastPersona?.voiceIdUsed || ''))
234
- // Cached audio is stale if the line or the voice identity changed since it was made.
235
- // Native (Web Speech) never caches, so it's never "dirty".
236
- const isDirty = () => !isNative() && hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || voiceNow() !== voiceUsed())
237
- // Only Qwen3 has a clone step: re-speak the SAME timbre when just the words changed.
238
- // A changed DESIGN text means a new timbre → re-design (cloning would keep the old voice).
239
- const designChanged = () => isDesign() && hasVoice && lastPersona && (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || '')
240
- // Badge when there's a persona but no current voice (none yet, or it went stale).
241
- function updateVoiceUI() {
242
- const needs = !!lastPersona && !isNative() && (!hasVoice || isDirty())
243
- playBtn.classList.toggle('badged', needs)
244
- if (playing) return // 'Stop' title owned by setPlaying while sounding
245
- playBtn.title = (!lastPersona || isNative()) ? 'Play voice' : (!hasVoice ? 'Create voice' : (isDirty() ? 'Update & play' : 'Play voice'))
246
- }
247
-
248
- // Reflect the active provider: Qwen3-TTS designs from the editable text; the others
249
- // use a named voice (text design goes read-only, the voice picker appears). Called on
250
- // show + whenever the panel comes back into view (the provider is set in Settings).
251
- function refreshVoiceMode() {
252
- const design = isDesign()
253
- voiceEl.contentEditable = design ? 'true' : 'false'
254
- voiceEl.classList.toggle('readonly', !design)
255
- voiceEl.setAttribute('data-ph', design ? 'How they sound…' : '(only used by Qwen3-TTS Voice Design)')
256
- voicePickRow.style.display = design ? 'none' : ''
257
- if (!design) {
258
- const voices = activeVoices()
259
- voicePickEl.replaceChildren(...voices.map((v) => el('option', { value: v.id }, v.label)))
260
- let cur = (lastPersona && lastPersona.voiceId) || activeDefaultVoice()
261
- if (!voices.some((v) => v.id === cur)) cur = voices[0] ? voices[0].id : ''
262
- voicePickEl.value = cur
263
- if (lastPersona) lastPersona.voiceId = cur
264
- }
265
- }
266
- voicePickEl.addEventListener('change', () => {
267
- if (!lastPersona) return
268
- lastPersona.voiceId = voicePickEl.value
269
- autosave(); updateVoiceUI()
270
- })
271
- // The provider is chosen on the Settings tab; re-render voice controls when it changes.
272
- onTtsEngineChange(() => { stopVoice(); if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } })
273
-
274
- function autosave() {
275
- if (!lastPersona) return
276
- const rec = savePersona({ ...lastPersona, id: savedId, unitClass: lastPersona.unitClass || sel.value, seed: lastPersona.seed || seed.value })
277
- savedId = rec.id
278
- }
279
-
280
- // Make a field click-to-edit; persist on blur (always save after edit — no button).
281
- function editable(elm, field, { single = false } = {}) {
282
- elm.contentEditable = 'true'
283
- elm.spellcheck = false
284
- if (single) elm.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); elm.blur() } })
285
- // Update the badge immediately as you type; persist on blur.
286
- if (field === 'quote' || field === 'voice') {
287
- elm.addEventListener('input', () => { if (lastPersona) { lastPersona[field] = elm.textContent.trim(); updateVoiceUI() } })
288
- }
289
- elm.addEventListener('blur', () => {
290
- if (!lastPersona) return
291
- lastPersona[field] = elm.textContent.trim()
292
- autosave(); updateVoiceUI()
293
- })
294
- }
295
- editable(nameEl, 'name', { single: true })
296
- editable(aboutEl, 'about')
297
- editable(quoteEl, 'quote', { single: true })
298
- editable(voiceEl, 'voice')
299
-
300
- // ── Portrait ──────────────────────────────────────────────────────────────
301
- const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
302
- const buildAppearance = (p) => [
303
- [p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
304
- (p.about || '').trim(),
305
- ].filter(Boolean).join('. ')
306
- const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
307
- const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
308
-
309
- function setPortrait(blob) {
310
- if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
311
- portraitUrl = blob ? URL.createObjectURL(blob) : null
312
- portraitImg.src = portraitUrl || ''
313
- portraitWrap.classList.toggle('has-img', !!blob)
314
- }
315
- function updatePortraitUI() {
316
- portraitBtn.classList.toggle('badged', !!lastPersona && (!hasPortrait || portraitDirty()))
317
- portraitBtn.title = !hasPortrait ? 'Paint portrait' : 'Repaint portrait'
318
- }
319
- // Make the appearance field click-to-edit (drives the prompt + the badge).
320
- appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
321
- appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
322
- appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
323
-
324
- // 🎨 Generate (or regenerate) the portrait from the appearance prompt; cache + persist.
325
- async function makePortrait() {
326
- if (portraitBusy || !lastPersona) return
327
- autosave() // ensure an id to key the image
328
- const appearance = appearanceFor(lastPersona)
329
- portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
330
- const prev = status.textContent
331
- try {
332
- // In-browser engines (Janus) download the model on first use — show progress.
333
- if (imageNeedsDownload()) {
334
- status.textContent = 'loading portrait model…'
335
- await ensureImage((f) => { status.textContent = `downloading portrait model… ${Math.round(f * 100)}% (one-time)` })
336
- }
337
- status.textContent = `painting with ${imageBackendLabel()}…`
338
- const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
339
- await putPortrait(savedId, blob)
340
- lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
341
- hasPortrait = true; setPortrait(blob); autosave()
342
- status.textContent = prev
343
- } catch (e) { status.textContent = `portrait failed: ${e.message || e}` }
344
- finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
345
- }
346
- portraitBtn.addEventListener('click', makePortrait)
347
-
348
- async function showPersona(p, opts = {}) {
349
- stopVoice() // picking another hero cuts the current voice
350
- lastPersona = { ...p }
351
- savedId = opts.savedId || null
352
- nameEl.textContent = p.name || ''
353
- aboutEl.textContent = p.about || ''
354
- quoteEl.textContent = p.quote || ''
355
- voiceEl.textContent = p.voice || ''
356
- appearanceEl.textContent = p.appearance || buildAppearance(p)
357
- hasVoice = savedId ? !!(await getAudio(savedId)) : false
358
- let pblob = null
359
- hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
360
- setPortrait(pblob)
361
- refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
362
- }
363
-
364
- // ▶ The one voice button: if the cached voice is current, just replay it. If the voice
365
- // DESIGN text changed (or there's no voice yet) → DESIGN a fresh timbre. If only the
366
- // spoken line changed → CLONE the last voice (same timbre, new words). Cache + save over.
367
- async function play() {
368
- if (playing) { stopVoice(); return } // second click while sounding → stop
369
- if (working || !lastPersona) return
370
- const line = lineFor(lastPersona)
371
-
372
- // Native provider (Web Speech): can't render to a file — speak the line live.
373
- if (isNative()) {
374
- setPlaying(true)
375
- try { await speakVoiceLive(lastPersona.voiceId || '', line) }
376
- catch (e) { status.textContent = `voice failed: ${e.message || e}` }
377
- finally { setPlaying(false) }
378
- return
379
- }
380
-
381
- // Up-to-date voice exists → just replay the cached file.
382
- if (hasVoice && !isDirty()) {
383
- const blob = savedId ? await getAudio(savedId) : null
384
- if (blob) { await playBuf(await blob.arrayBuffer()); return }
385
- hasVoice = false // cache vanished — fall through to re-make it
386
- }
387
- if (isDesign() && !lastPersona.voice) { status.textContent = 'add a voice design first'; return }
388
- autosave() // ensure an id to key the audio
389
-
390
- const design = isDesign()
391
- // Qwen3 clones (same timbre, new words) only when the design text is unchanged;
392
- // fixed-voice providers always re-synth the line in the picked named voice.
393
- const reclone = design && hasVoice && !designChanged()
394
- // Generating → the ▶ becomes a loading spinner (.busy) until the WAV is ready.
395
- working = true; playBtn.classList.add('busy'); playBtn.disabled = true
396
- const prev = status.textContent
397
- status.textContent = reclone ? 'updating the voice…' : (design ? 'designing the voice…' : 'creating the voice…')
398
- let wav = null
399
- try {
400
- if (design && reclone) {
401
- const blob = await getAudio(savedId)
402
- wav = await cloneVoiceWav(await blob.arrayBuffer(), lastPersona.voiceQuote || '', line, lastPersona.voice || '')
403
- } else if (design) {
404
- wav = await createVoiceWav(lastPersona.voice, line)
405
- } else {
406
- wav = await synthVoiceWav(lastPersona.voiceId || '', line)
407
- }
408
- await putAudio(savedId, new Blob([wav], { type: 'audio/wav' })) // save over
409
- lastPersona.voiceQuote = line
410
- lastPersona.voiceDesignUsed = lastPersona.voice || ''
411
- lastPersona.voiceIdUsed = lastPersona.voiceId || ''
412
- hasVoice = true; autosave()
413
- status.textContent = prev
414
- } catch (e) { status.textContent = `voice failed: ${e.message || e}` }
415
- finally { working = false; playBtn.classList.remove('busy'); playBtn.disabled = false; updateVoiceUI() }
416
- if (wav) await playBuf(wav.slice(0)) // spinner cleared → now toggles to ⏹ while sounding
417
- }
418
- playBtn.addEventListener('click', play)
419
-
420
- // Navigating to another tab hides this stage (Gradio sets display:none) → the host
421
- // stops intersecting; cut the voice so it doesn't keep playing off-screen.
422
- try {
423
- new IntersectionObserver((entries) => {
424
- for (const e of entries) {
425
- if (!e.isIntersecting) { if (playing) stopVoice() }
426
- else if (lastPersona) { refreshVoiceMode(); updateVoiceUI() } // provider may have changed in Settings
427
- }
428
- }).observe(host)
429
- } catch { /* no IntersectionObserver — playback just won't auto-stop on nav */ }
430
-
431
- // ── Barracks roster (saved heroes) ──────────────────────────────────────
432
- function renderRoster(personas) {
433
- if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
434
- rosterEl.replaceChildren(...personas.map((p) =>
435
- el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
436
- el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
437
- `${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
438
- el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
439
- ])))
440
- }
441
- renderRoster(listPersonas())
442
- onRosterChange(renderRoster)
443
-
444
- let lastDebug = ''
445
- function buildDebug(outcome, acc) {
446
- const stripped = stripThinkFinal(acc || '')
447
- return [
448
- '=== TINY ARMY · PERSONA DEBUG ===',
449
- `engine: ${getEngineId()} · ${backendLabel()}`,
450
- `model: ${currentModelId()} (${currentModel().label})`,
451
- `input: class=${sel.value} seed=${seed.value || '(none)'} maxTokens=${MAX_TOKENS}`,
452
- `outcome: ${outcome}`,
453
- `--- raw output (${(acc || '').length} chars) ---`, acc || '(empty)',
454
- `--- after stripThink → parser (${stripped.length} chars) ---`, stripped || '(empty)',
455
- ].join('\n')
456
- }
457
- copyBtn.addEventListener('click', async () => {
458
- const text = lastDebug || buildDebug('(no generation yet)', '')
459
- try {
460
- await navigator.clipboard.writeText(text)
461
- copyBtn.textContent = '✓ copied'; setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
462
- } catch {
463
- thinkEl.textContent = text; thinkWrap.open = true
464
- const r = document.createRange(); r.selectNodeContents(thinkEl)
465
- const s = getSelection(); s.removeAllRanges(); s.addRange(r)
466
- copyBtn.textContent = 'selected ↓ — ⌘/Ctrl+C'
467
- }
468
- })
469
-
470
- async function generate() {
471
- if (busy) return
472
- busy = true; btn.disabled = true; refreshVisibility()
473
- if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
474
- nameEl.textContent = '…'; aboutEl.textContent = ''
475
- quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
476
- lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
477
- stopVoice(); setPortrait(null); updateVoiceUI(); updatePortraitUI()
478
- thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
479
- let acc = ''
480
- try {
481
- status.textContent = `loading ${currentModel().label}…`
482
- await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
483
- status.textContent = `writing on your device with ${currentModel().label}…`
484
- await streamChat(getPersonaSystem(), personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
485
- maxTokens: MAX_TOKENS,
486
- onToken: (piece) => {
487
- acc += piece
488
- thinkEl.textContent = acc; thinkEl.scrollTop = thinkEl.scrollHeight
489
- const live = extractLivePersona(stripThink(acc))
490
- if (live.name) nameEl.textContent = live.name
491
- if (live.about) aboutEl.textContent = live.about
492
- },
493
- onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
494
- })
495
- try {
496
- const p = parsePersonaJson(stripThinkFinal(acc))
497
- await showPersona(p)
498
- autosave() // generated personas are saved immediately (no Save button)
499
- renderRoster(listPersonas())
500
- status.textContent = 'enlisted ✓ (saved) — edit any field, or create a voice'
501
- lastDebug = buildDebug('parsed OK', acc); thinkWrap.open = false
502
- } catch (e) {
503
- status.textContent = `the model rambled — couldn't parse a clean persona (${e.message || e}) · 📋 Copy debug`
504
- lastDebug = buildDebug('PARSE ERROR: ' + (e.message || e), acc); thinkWrap.open = true
505
- }
506
- } catch (e) {
507
- status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
508
- lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc); thinkWrap.open = true
509
- } finally { busy = false; btn.disabled = false; refreshVisibility() }
510
- }
511
- btn.addEventListener('click', generate)
512
  }
 
1
+ // Tiny Army persona panel — mounted by tiny.js into #persona-stage. It's just the shared hero
2
+ // creator (heroCreator.js recruit/stream/edit/voice/portrait, autosaved) with its barracks roster
3
+ // of saved heroes enabled.
4
+ import { mountHeroCreator, CLASS_SLUG } from '/web/heroCreator.js'
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ // Re-exported so existing importers (tiny.js) keep working.
7
+ export { CLASS_SLUG }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  export function mountPersonaPanel(host) {
10
+ mountHeroCreator(host, { showBarracks: true })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
web/shell/persona.css CHANGED
@@ -138,6 +138,9 @@
138
  border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
139
  }
140
  .persona-ico:hover { background: var(--p-paper-2) !important; }
 
 
 
141
  .persona-ico.busy { cursor: default; }
142
  /* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
143
  .persona-ico.busy { color: transparent !important; }
@@ -355,3 +358,126 @@
355
  .model-select { font-size: 15px !important; padding: 9px 10px !important; }
356
  .persona-go, .persona-go-alt { padding: 12px !important; }
357
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important; padding: 3px 9px !important;
139
  }
140
  .persona-ico:hover { background: var(--p-paper-2) !important; }
141
+ /* Disabled = up to date (no change to repaint); dim it (but not while busy — that shows a spinner). */
142
+ .persona-ico:disabled:not(.busy) { opacity: .4; cursor: default; }
143
+ .persona-ico:disabled:not(.busy):hover { background: var(--p-card) !important; }
144
  .persona-ico.busy { cursor: default; }
145
  /* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
146
  .persona-ico.busy { color: transparent !important; }
 
358
  .model-select { font-size: 15px !important; padding: 9px 10px !important; }
359
  .persona-go, .persona-go-alt { padding: 12px !important; }
360
  }
361
+
362
+ /* ── Game-page "Create a Hero" modal — hosts the shared creator (.persona-view) in a card over the
363
+ * dimmed map. Palette vars are re-declared here so the modal chrome (head/foot) matches the
364
+ * parchment body even though those vars are normally scoped to .persona-view. */
365
+ .hero-modal-backdrop {
366
+ position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center;
367
+ padding: 20px; background: rgba(8, 11, 16, .62); backdrop-filter: blur(2px);
368
+ }
369
+ .hero-modal {
370
+ --p-ink: #141821; --p-muted: #6d6a5f; --p-paper: #f3ebdc; --p-paper-2: #ece2cc;
371
+ --p-card: #fbf6ea; --p-transmit: #d8271a;
372
+ --p-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
373
+ /* FIXED height so the modal never resizes between Create a Hero ⇄ Hero Details or while text
374
+ * streams in — the two columns scroll internally instead. */
375
+ width: min(940px, 96vw); height: min(620px, 90vh); display: flex; flex-direction: column;
376
+ background: var(--p-paper); color: var(--p-ink); font-family: var(--p-sans);
377
+ border-radius: 14px; overflow: hidden; box-shadow: 0 24px 70px rgba(0, 0, 0, .55);
378
+ }
379
+ .hero-modal-head {
380
+ display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
381
+ padding: 12px 16px; border-bottom: 1px solid var(--p-paper-2); background: var(--p-card);
382
+ }
383
+ .hero-modal-title { font-weight: 700; font-size: 16px; letter-spacing: .01em; }
384
+ .hero-modal-x {
385
+ border: 0; background: none; color: var(--p-muted); font-size: 20px; line-height: 1;
386
+ cursor: pointer; padding: 4px 8px; border-radius: 8px;
387
+ }
388
+ .hero-modal-x:hover { background: var(--p-paper-2); color: var(--p-ink); }
389
+ .hero-modal-body { flex: 1; min-height: 0; overflow: hidden; }
390
+ /* The creator's two-column view fills the fixed body; each column scrolls inside it. */
391
+ .hero-modal-body .persona-view { height: 100%; }
392
+ .hero-modal-foot {
393
+ display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-shrink: 0;
394
+ padding: 12px 16px; border-top: 1px solid var(--p-paper-2); background: var(--p-card);
395
+ }
396
+ .hero-modal-cancel, .hero-modal-save {
397
+ font-family: var(--p-sans); font-weight: 600; font-size: 14px; cursor: pointer;
398
+ padding: 9px 16px; border-radius: 9px; border: 1px solid var(--p-paper-2);
399
+ }
400
+ .hero-modal-cancel { background: transparent; color: var(--p-muted); }
401
+ .hero-modal-cancel:hover { color: var(--p-ink); border-color: var(--p-muted); }
402
+ .hero-modal-save { background: var(--p-transmit); color: #fff; border-color: var(--p-transmit); }
403
+ .hero-modal-save:hover { filter: brightness(1.06); }
404
+ .hero-modal-save:disabled { opacity: .45; cursor: not-allowed; filter: none; }
405
+
406
+ /* The "+ Create hero" card in the Game hero picker. */
407
+ .hero-pick-create {
408
+ border-style: dashed !important; color: #cfd6df !important;
409
+ }
410
+ .hero-pick-create:hover { border-color: #4a5765 !important; background: rgba(40, 48, 60, .92) !important; }
411
+ .hero-pick-create .hero-pick-plus { font-size: 26px; line-height: 1; color: #aab3c0; }
412
+
413
+ /* ── Left-column states: recruit controls (A) ⇄ portrait panel (B). The model output is anchored to
414
+ * the bottom of the column; the portrait box shows a framed placeholder until it's painted. ── */
415
+ .persona-recruit-box, .persona-portrait-panel { display: flex; flex-direction: column; gap: 8px; }
416
+ .persona-controls > .persona-think-wrap { margin-top: auto; } /* anchor model output to the bottom */
417
+ .persona-back {
418
+ align-self: flex-start; font-family: var(--p-mono); font-size: 11px; letter-spacing: .06em;
419
+ color: var(--p-muted); background: none; border: 0; cursor: pointer; padding: 2px 0;
420
+ }
421
+ .persona-back:hover { color: var(--p-ink); }
422
+ /* Portrait box: always a framed square in the panel (placeholder before painting, image after). */
423
+ .persona-portrait-panel .persona-portrait-wrap {
424
+ display: flex; align-items: center; justify-content: center; margin-top: 4px;
425
+ width: 100%; aspect-ratio: 1 / 1; background: var(--p-card);
426
+ border: 1.5px dashed var(--p-muted); box-shadow: 4px 4px 0 var(--p-transmit);
427
+ }
428
+ .persona-portrait-panel .persona-portrait-wrap.has-img { border-style: solid; border-color: var(--p-ink); }
429
+ .persona-portrait-panel .persona-portrait-wrap:not(.has-img)::after {
430
+ content: 'portrait appears here'; font-family: var(--p-mono); font-size: 10px;
431
+ letter-spacing: .12em; text-transform: uppercase; color: var(--p-muted);
432
+ }
433
+ .persona-portrait-panel .persona-portrait-img {
434
+ width: 100%; height: 100%; aspect-ratio: auto; object-fit: cover; border: 0; box-shadow: none; display: none;
435
+ }
436
+ .persona-portrait-panel .persona-portrait-wrap.has-img .persona-portrait-img { display: block; }
437
+
438
+ /* Inline per-action status (e.g. "painting via …", "generating voice via …") beside a section's
439
+ * create button; the button + note sit together on the right of the heading. */
440
+ /* Action header: the create button with its status note to the RIGHT; the note wraps (never
441
+ * truncates) so the full model name always shows. */
442
+ .persona-sec-action { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
443
+ .persona-sec-action > .persona-ico { flex-shrink: 0; }
444
+ /* The note stays BESIDE the button and wraps its own text (tiny font), so it never pushes the
445
+ * portrait/box below it down even with a full model name. */
446
+ .persona-act-status {
447
+ flex: 1; min-width: 0;
448
+ font-family: var(--p-mono); font-size: 8px; letter-spacing: .02em; color: var(--p-muted);
449
+ white-space: normal; line-height: 1.2;
450
+ }
451
+ /* Always-visible barracks block in the left column. */
452
+ .persona-barracks { display: flex; flex-direction: column; gap: 4px; }
453
+ /* Status + token rate now live inside the debug <details>; give them a little breathing room. */
454
+ .persona-think-wrap > .persona-status, .persona-think-wrap > .persona-stats { margin: 6px 0 0; }
455
+ /* ← Back in the modal footer sits to the LEFT of Cancel / Save & Play. */
456
+ .hero-modal-foot { gap: 10px; }
457
+ .hero-modal-foot .persona-back { margin-right: auto; font-size: 13px; }
458
+
459
+ /* ── Hero detail page (Game): a dark card shown before spawning — big portrait/idle + about/quote
460
+ * and a Select button. ── */
461
+ .hero-detail-backdrop {
462
+ position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center;
463
+ padding: 20px; background: rgba(8, 11, 16, .7); backdrop-filter: blur(2px); font-family: var(--tac-font, system-ui);
464
+ }
465
+ .hero-detail {
466
+ width: min(380px, 94vw); max-height: 92vh; overflow: auto; display: flex; flex-direction: column;
467
+ background: #14181f; color: #e8e8e8; border: 1px solid #2a3340; border-radius: 14px; box-shadow: 0 24px 70px rgba(0, 0, 0, .6);
468
+ }
469
+ .hero-detail-portrait {
470
+ width: 240px; height: 240px; margin: 18px auto 4px; border-radius: 12px;
471
+ background: #0b0e12 center no-repeat; background-size: cover; border: 1px solid #20262e; box-shadow: 0 4px 18px rgba(0, 0, 0, .4);
472
+ }
473
+ .hero-detail-info { padding: 8px 18px 4px; display: flex; flex-direction: column; gap: 8px; }
474
+ .hero-detail-name { font-size: 22px; font-weight: 700; line-height: 1.1; }
475
+ .hero-detail-class { color: #8a93a0; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; margin-top: -4px; }
476
+ .hero-detail-about { font-size: 14px; line-height: 1.55; color: #c8cdd4; }
477
+ .hero-detail-quote { margin: 2px 0 0; padding: 2px 0 2px 12px; border-left: 3px solid #d8271a; font-style: italic; color: #e8d8a0; font-size: 15px; line-height: 1.4; }
478
+ .hero-detail-foot { display: flex; gap: 10px; justify-content: flex-end; padding: 14px 18px 18px; }
479
+ .hero-detail-back, .hero-detail-select { font: 600 14px var(--tac-font, system-ui); cursor: pointer; padding: 9px 18px; border-radius: 10px; border: 1px solid #2a3340; }
480
+ .hero-detail-back { background: transparent; color: #aab3c0; }
481
+ .hero-detail-back:hover { color: #e8e8e8; border-color: #4a5765; }
482
+ .hero-detail-select { background: #d8271a; color: #fff; border-color: #d8271a; }
483
+ .hero-detail-select:hover { filter: brightness(1.08); }
web/tiny.js CHANGED
@@ -10,7 +10,8 @@ import { mountEnemiesSandbox } from '/web/enemiesSandbox.js'
10
  import { mountMapSandbox } from '/web/mapSandbox.js'
11
  import { mountComboBattler } from '/web/comboBattler.js'
12
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
13
- import { listPersonas } from '/web/personaStore.js'
 
14
  import { mountDiaryPanel } from '/web/diaryPanel.js'
15
  import { mountSettingsPanel } from '/web/settingsPanel.js'
16
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
@@ -215,9 +216,49 @@ const buildPlayer = (chars, p) => {
215
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
216
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
217
  }
218
- // Bottom-of-screen hero picker: a card per saved persona (idle-sprite avatar + name). onPick gets the
219
- // chosen persona record. Overlays the map (pointer-events only on the cards, so the map still pans).
220
- function buildHeroPicker(host, personas, chars, onPick) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  const bar = document.createElement('div')
222
  bar.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 12px 18px;pointer-events:none;background:linear-gradient(to top,rgba(8,11,16,.86),rgba(8,11,16,0));font-family:var(--tac-font,system-ui)'
223
  const title = document.createElement('div')
@@ -228,10 +269,10 @@ function buildHeroPicker(host, personas, chars, onPick) {
228
  for (const p of personas) {
229
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
230
  const card = document.createElement('button')
231
- card.style.cssText = '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)'
232
  const av = document.createElement('div')
233
- av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12 0% 0%/400% 400% no-repeat;image-rendering:pixelated;border:1px solid #20262e'
234
- if (pc?.idle) av.style.backgroundImage = `url(${spriteUrl(pc.idle)})`
235
  const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
236
  nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
237
  card.append(av, nm)
@@ -239,9 +280,51 @@ function buildHeroPicker(host, personas, chars, onPick) {
239
  card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
240
  row.appendChild(card)
241
  }
 
 
 
 
 
 
 
 
 
242
  bar.append(title, row); host.appendChild(bar)
243
  return bar
244
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  whenEl('battle-stage', async (el) => {
246
  unprose(el)
247
  const chars = await loadChars()
@@ -255,15 +338,29 @@ whenEl('battle-stage', async (el) => {
255
  comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
256
  await comboCtrl.ready
257
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
 
258
  let picker = null
259
- const showPicker = () => {
260
- if (picker) return
 
 
 
 
 
 
 
 
 
 
261
  const personas = listPersonas()
262
- picker = buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, (p) => {
263
- picker?.remove(); picker = null
264
- comboCtrl.selectHero(buildPlayer(chars, p))
265
- })
266
  }
 
 
 
 
267
  showPicker()
268
  comboCtrl.onChange((s) => { if (s.over) showPicker() })
 
269
  })
 
10
  import { mountMapSandbox } from '/web/mapSandbox.js'
11
  import { mountComboBattler } from '/web/comboBattler.js'
12
  import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
13
+ import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
14
+ import { listPersonas, onRosterChange, getPortrait } from '/web/personaStore.js'
15
  import { mountDiaryPanel } from '/web/diaryPanel.js'
16
  import { mountSettingsPanel } from '/web/settingsPanel.js'
17
  import { mountSkillForgePanel } from '/web/skillForgePanel.js'
 
216
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
217
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
218
  }
219
+ 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)'
220
+ // Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
221
+ // of their class (same loop as the class selector). `sizePx` sizes the idle-sprite cell math.
222
+ async function fillAvatar(box, persona, chars, sizePx) {
223
+ let blob = null
224
+ try { if (persona?.id) blob = await getPortrait(persona.id) } catch { /* none */ }
225
+ box.getAnimations?.().forEach((a) => a.cancel())
226
+ if (blob) {
227
+ box.style.backgroundImage = `url(${URL.createObjectURL(blob)})`
228
+ box.style.backgroundSize = 'cover'; box.style.backgroundPosition = 'center'; box.style.imageRendering = 'auto'
229
+ } else {
230
+ box.style.imageRendering = 'pixelated'
231
+ const pc = chars[CLASS_SLUG[persona?.unitClass]] || chars['true-heroes-iii-fighter']
232
+ if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
233
+ }
234
+ }
235
+ // Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
236
+ // onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
237
+ function openHeroDetail(host, persona, chars, onSelect) {
238
+ const backdrop = document.createElement('div'); backdrop.className = 'hero-detail-backdrop'
239
+ const card = document.createElement('div'); card.className = 'hero-detail'
240
+ const portrait = document.createElement('div'); portrait.className = 'hero-detail-portrait'
241
+ fillAvatar(portrait, persona, chars, 240)
242
+ const info = document.createElement('div'); info.className = 'hero-detail-info'
243
+ const name = document.createElement('div'); name.className = 'hero-detail-name'; name.textContent = persona?.name || 'Hero'
244
+ info.append(name)
245
+ if (persona?.unitClass) { const c = document.createElement('div'); c.className = 'hero-detail-class'; c.textContent = persona.unitClass; info.append(c) }
246
+ if (persona?.about) { const a = document.createElement('div'); a.className = 'hero-detail-about'; a.textContent = persona.about; info.append(a) }
247
+ if (persona?.quote) { const q = document.createElement('blockquote'); q.className = 'hero-detail-quote'; q.textContent = persona.quote; info.append(q) }
248
+ const foot = document.createElement('div'); foot.className = 'hero-detail-foot'
249
+ const back = document.createElement('button'); back.className = 'hero-detail-back'; back.type = 'button'; back.textContent = 'Back'
250
+ const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
251
+ foot.append(back, select)
252
+ card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
253
+ const close = () => backdrop.remove()
254
+ back.addEventListener('click', close)
255
+ backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
256
+ select.addEventListener('click', () => { close(); onSelect() })
257
+ }
258
+ // Bottom-of-screen hero picker: a card per saved persona (idle-sprite avatar + name) plus a
259
+ // "+ Create hero" card. onPick gets the chosen persona; onCreate opens the create modal. Overlays
260
+ // the map (pointer-events only on the cards, so the map still pans).
261
+ function buildHeroPicker(host, personas, chars, onPick, onCreate) {
262
  const bar = document.createElement('div')
263
  bar.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 12px 18px;pointer-events:none;background:linear-gradient(to top,rgba(8,11,16,.86),rgba(8,11,16,0));font-family:var(--tac-font,system-ui)'
264
  const title = document.createElement('div')
 
269
  for (const p of personas) {
270
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
271
  const card = document.createElement('button')
272
+ card.style.cssText = PICK_CARD_CSS
273
  const av = document.createElement('div')
274
+ av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12 no-repeat;border:1px solid #20262e'
275
+ fillAvatar(av, p, chars, 48) // portrait if saved, else animated class idle
276
  const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
277
  nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
278
  card.append(av, nm)
 
280
  card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
281
  row.appendChild(card)
282
  }
283
+ // "+ Create hero" card → opens the create modal.
284
+ const create = document.createElement('button')
285
+ create.className = 'hero-pick-create'; create.style.cssText = PICK_CARD_CSS
286
+ const plus = document.createElement('div'); plus.className = 'hero-pick-plus'; plus.textContent = '+'
287
+ plus.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center'
288
+ const clbl = document.createElement('div'); clbl.textContent = 'Create hero'; clbl.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
289
+ create.append(plus, clbl)
290
+ create.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onCreate && onCreate() })
291
+ row.appendChild(create)
292
  bar.append(title, row); host.appendChild(bar)
293
  return bar
294
  }
295
+ // "Create a Hero" modal over the map: hosts the shared creator (heroCreator.js — stream details,
296
+ // generate a portrait + quote + voice, autosaved to the roster). onCreated(persona) fires on
297
+ // "Save & Play"; Cancel/✕/backdrop just close (a generated hero is already saved to the barracks).
298
+ function openCreateModal(host, onCreated) {
299
+ const backdrop = document.createElement('div'); backdrop.className = 'hero-modal-backdrop'
300
+ const card = document.createElement('div'); card.className = 'hero-modal'
301
+ const head = document.createElement('div'); head.className = 'hero-modal-head'
302
+ const title = document.createElement('div'); title.className = 'hero-modal-title'; title.textContent = 'Create a Hero'
303
+ const x = document.createElement('button'); x.className = 'hero-modal-x'; x.type = 'button'; x.title = 'Close'; x.textContent = '✕'
304
+ head.append(title, x)
305
+ const body = document.createElement('div'); body.className = 'hero-modal-body'
306
+ const foot = document.createElement('div'); foot.className = 'hero-modal-foot'
307
+ const cancel = document.createElement('button'); cancel.className = 'hero-modal-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel'
308
+ const save = document.createElement('button'); save.className = 'hero-modal-save'; save.type = 'button'; save.textContent = 'Save & Play ▶'; save.disabled = true
309
+ foot.append(cancel, save) // the creator prepends its ← Back here (shown only once a hero exists)
310
+ card.append(head, body, foot); backdrop.append(card); host.appendChild(backdrop)
311
+
312
+ // Save & Play is enabled once a hero is saved; ← Back resets the creator → recruit state → disabled.
313
+ const creator = mountHeroCreator(body, {
314
+ showBarracks: true,
315
+ backSlot: foot,
316
+ onSaved: () => { save.disabled = false },
317
+ onState: (s) => { title.textContent = s === 'portrait' ? 'Hero Details' : 'Create a Hero'; if (s === 'recruit') save.disabled = true },
318
+ })
319
+ const close = () => { try { creator.stop() } catch { /* ignore */ } backdrop.remove() }
320
+ x.addEventListener('click', close)
321
+ cancel.addEventListener('click', close)
322
+ backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
323
+ save.addEventListener('click', () => {
324
+ const cur = creator.current(); if (!cur.persona) return
325
+ close(); onCreated(cur.persona)
326
+ })
327
+ }
328
  whenEl('battle-stage', async (el) => {
329
  unprose(el)
330
  const chars = await loadChars()
 
338
  comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
339
  await comboCtrl.ready
340
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
341
+ const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
342
  let picker = null
343
+ // Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
344
+ // the spawn point and drops them in there.
345
+ const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
346
+ const spawnWithFly = (p) => {
347
+ picker?.remove(); picker = null
348
+ const s = comboCtrl.getSpawnWorld()
349
+ comboCtrl.selectHero(buildPlayer(chars, p))
350
+ comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
351
+ }
352
+ // Picking a hero opens its detail page (portrait/about/quote + Select); Select confirms → spawnWithFly.
353
+ const onPick = (p) => openHeroDetail(el, p, chars, () => spawnWithFly(p))
354
+ const buildPicker = () => {
355
  const personas = listPersonas()
356
+ return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
357
+ () => openCreateModal(el, spawnWithFly))
 
 
358
  }
359
+ const showPicker = () => { if (!picker) { picker = buildPicker(); flyToOverview() } }
360
+ // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
361
+ // up in the "Choose your hero" bar without waiting for the next time it reopens.
362
+ const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
363
  showPicker()
364
  comboCtrl.onChange((s) => { if (s.over) showPicker() })
365
+ onRosterChange(refreshPicker)
366
  })