File size: 24,403 Bytes
2e524a0
4f0862a
 
 
 
2e524a0
0c573fe
38a1e17
 
c7dba29
5bdce49
 
3ef6bd6
 
03708ca
627d835
1f1908e
2e524a0
c7dba29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3cb070
 
 
 
 
 
c7dba29
 
 
2e524a0
 
 
 
 
 
 
 
 
 
c7dba29
 
 
 
 
 
 
 
 
4f0862a
 
c2de8fb
4f0862a
2e524a0
 
c2de8fb
2e524a0
 
 
 
4f0862a
5bdce49
4f0862a
5bdce49
b450bdd
 
0c573fe
4f0862a
0c573fe
4f0862a
0c573fe
 
 
 
 
2e524a0
c7dba29
0c573fe
 
2e524a0
 
38a1e17
 
 
 
 
 
c7dba29
38a1e17
 
 
 
 
 
 
 
 
 
c7dba29
38a1e17
 
 
 
 
 
 
 
 
 
c7dba29
 
 
 
 
 
03708ca
67f4321
03708ca
1f1908e
dffe06d
 
cd43499
67f4321
992a52d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dffe06d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bdce49
 
 
 
 
992a52d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bdce49
 
 
0abf9a2
 
 
 
 
3ef6bd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0abf9a2
 
 
 
 
 
 
 
 
 
3ef6bd6
0abf9a2
3ef6bd6
 
0abf9a2
 
 
 
 
 
 
3ef6bd6
 
 
 
 
 
 
 
 
0abf9a2
 
 
3ef6bd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e524a0
5bdce49
 
992a52d
5bdce49
 
 
992a52d
0abf9a2
 
 
5bdce49
0abf9a2
3ef6bd6
0abf9a2
3ef6bd6
 
 
 
 
 
 
 
 
 
 
 
0abf9a2
3ef6bd6
 
0abf9a2
3ef6bd6
 
 
 
0abf9a2
 
3ef6bd6
2e524a0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
// Tiny Army — head module for the Gradio app. Renders Pixi into the gr.HTML
// canvas divs (#battle-stage, #sprite-stage). The slicing + facing/anim
// convention come from the SAME shared source as the auto-battler
// (src/render/spriteSheet.js → /web/sheet.js). Pixi is injected (CDN) so there's
// one Pixi instance.
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
import { mountSpritePlayground } from '/web/playground.js'
import { mountClassesSandbox } from '/web/classesSandbox.js'
import { mountEnemiesSandbox } from '/web/enemiesSandbox.js'
import { mountMapSandbox } from '/web/mapSandbox.js'
import { mountComboBattler } from '/web/comboBattler.js'
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
import { listPersonas, onRosterChange, getPortrait } from '/web/personaStore.js'
import { mountDiaryPanel } from '/web/diaryPanel.js'
import { mountSettingsPanel } from '/web/settingsPanel.js'
import { mountSkillForgePanel } from '/web/skillForgePanel.js'

// ── Asset-URL shim ───────────────────────────────────────────────────────────
// The map renderers (and their Tilesheet / mockup <img>s) hardcode '/assets/minifantasy/…'
// paths. The Space serves those at /sprites/… — but '/assets' is ALSO Gradio's own UI bundle,
// which we must NOT touch. So rewrite ONLY the sprite sub-roots, at every load path the
// renderers use: Pixi texture loads, <img> src, and fetch. This lets mapSandbox.js stay
// byte-identical to the React app's copy — the host (here) does the remap.
const reroot = (u) => (typeof u === 'string'
  ? u.replace(/^\/assets\/(minifantasy|derived|generated)\//, '/sprites/$1/') // sprite/tileset roots
    .replace(/^\/assets\/([^/?#]+\.json)/, '/sprites/$1') // data files fetched by hardcoded path (effects.json, …)
  : u)
const _assetsLoad = PIXI.Assets.load.bind(PIXI.Assets)
PIXI.Assets.load = (src, ...rest) => _assetsLoad(Array.isArray(src) ? src.map(reroot) : reroot(src), ...rest)
const _imgSrc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')
Object.defineProperty(HTMLImageElement.prototype, 'src', {
  configurable: true, enumerable: _imgSrc.enumerable,
  get() { return _imgSrc.get.call(this) },
  set(v) { _imgSrc.set.call(this, reroot(v)) },
})
// …and via setAttribute('src', …) too — the shared h() DOM helper sets img src that way
// (e.g. the Forgotten Plains Reference mockup), which bypasses the property setter above.
const _imgSetAttr = HTMLImageElement.prototype.setAttribute
HTMLImageElement.prototype.setAttribute = function (name, value) {
  return _imgSetAttr.call(this, name, (name === 'src' || name === 'srcset') ? reroot(value) : value)
}
const _fetch = window.fetch.bind(window)
window.fetch = (input, init) => _fetch(typeof input === 'string' ? reroot(input) : input, init)

function whenEl(id, cb) {
  const found = document.getElementById(id)
  if (found && !found.dataset.tmounted) { found.dataset.tmounted = '1'; cb(found); return }
  const o = new MutationObserver(() => {
    const el = document.getElementById(id)
    if (el && !el.dataset.tmounted) { el.dataset.tmounted = '1'; o.disconnect(); cb(el) }
  })
  o.observe(document.body, { childList: true, subtree: true })
}

// Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
// (.gradio-container-X .prose h2 / .prose * ; .gradio-container-X .gradio-style button) outrank the
// shared component CSS — recolouring/resizing our headings and stripping pill/button borders +
// padding. The sandbox stages fully own their styling, so drop BOTH classes from their wrapper
// chain. (NOT applied to the diary/persona panels — those ARE prose content.)
const unprose = (el) => {
  for (let n = el; n && n !== document.body; n = n.parentElement) { n.classList?.remove('prose'); n.classList?.remove('gradio-style') }
}

// Character manifest (slug → sheet URLs). Sheets are served at /sprites (not
// /assets — that's Gradio's bundle); the manifest paths are authored as /assets/…
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/')
let charMap = null
async function loadChars() {
  if (charMap) return charMap
  const d = await fetch('/sprites/characters.json').then((r) => r.json())
  charMap = {}
  for (const p of d.packs || []) for (const c of p.characters || []) charMap[c.slug] = c
  return charMap
}

let playground = null, comboCtrl = null
window.tinyResize = () => {
  try { comboCtrl?.resize?.() } catch {}
  // Re-fit + re-centre: the sprite stage mounts in a hidden (0-size) tab, so the
  // character must be re-placed once the tab is actually shown.
  try { if (playground) { playground.resize(); playground.recenter() } } catch {}
}
window.tinySnap = () => (playground ? playground.getSnapshot() : null) // read-only debug accessor

// ── Sprite Animations tab — shared playground ────────────────────────────────
// The SAME module the React app uses (auto-battler src/render/spritePlayground.js
// → /web/playground.js, Pixi injected) builds the entire page: team-grouped
// character picker, stage, canvas, instructions, aimed-attack compass and extras
// list — and the shared render core. styled by /web/shell/spriteScene.css.
whenEl('sprite-stage', async (el) => {
  unprose(el)
  const man = await fetch('/sprites/characters.json').then((r) => r.json())
  playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl })
})

// ── Classes sandbox — the shared playground (auto-battler src/render/classesSandbox.js
// → /web/classesSandbox.js, Pixi injected) builds the whole page: class picker + WASD
// combat + customize panel. Sheet/effect URLs in the data are authored as /assets/…;
// the Space serves them at /sprites/…, so we remap before handing the data over.
const remapAssets = (o) => JSON.parse(JSON.stringify(o).replaceAll('/assets/', '/sprites/'))
whenEl('classes-stage', async (el) => {
  unprose(el)
  const j = (u) => fetch(u).then((r) => r.json()).catch(() => ({}))
  const [chars, effects, classes] = await Promise.all([j('/sprites/characters.json'), j('/sprites/effects.json'), j('/sprites/classes.json')])
  mountClassesSandbox(PIXI, el, {
    packs: remapAssets(chars).packs || [],
    fx: remapAssets(effects).effects || [],
    config: { classes: {}, skills: {}, ...remapAssets(classes) },
    editable: false, // no /api on the Space — the customize panel is read-only
  })
})
whenEl('enemies-stage', async (el) => {
  unprose(el)
  const j = (u) => fetch(u).then((r) => r.json()).catch(() => ({}))
  const [chars, effects, enemies] = await Promise.all([j('/sprites/characters.json'), j('/sprites/effects.json'), j('/sprites/enemies.json')])
  mountEnemiesSandbox(PIXI, el, {
    packs: remapAssets(chars).packs || [],
    fx: remapAssets(effects).effects || [],
    config: { enemies: {}, ...remapAssets(enemies) },
    editable: false,
  })
})

// ── World Map tab — the shared Map sandbox (auto-battler src/render/mapSandbox.js
// → /web/mapSandbox.js, Pixi injected): the pill switcher + all six sub-pages (World Map /
// Necropolis / Orc Kingdom / Forgotten Plains / Interiors / Towers). The renderers hardcode
// '/assets/minifantasy/…' URLs; the shim above reroots them to /sprites for the Space.
whenEl('worldmap-stage', (el) => { unprose(el); mountMapSandbox(PIXI, el, { page: 'world' }) })

// ── Personas + War Diary tabs — in-browser llama.cpp (wllama), runs on the device ──
whenEl('persona-stage', (el) => { mountPersonaPanel(el) })
whenEl('diary-stage', (el) => { mountDiaryPanel(el) })
whenEl('skillforge-stage', (el) => { mountSkillForgePanel(el) })
// Engine + model + voice pickers are injected into Gradio's own Settings page (footer
// link / sidebar ⚙), shared across pages via the runtime.js + tts.js singletons.
mountSettingsPanel()

// Relocate Gradio's footer links — Use via API, Built with Gradio, Settings — into the sidebar's App
// slot (#tac-extlinks), then hide the footer. We move the LIVE elements, so each keeps its native
// behaviour (API panel / open gradio.app / open Settings). They are styled via .tac-extlink only —
// NOT .tac-nav-item, which would make sidebar.js hijack the click (mark active=white, swallow nav).
function relocateFooterLinks() {
  const wanted = (t) => /^(use via api|built with gradio|settings)$/i.test((t || '').trim())
  const move = () => {
    const footer = document.querySelector('footer')
    const host = document.getElementById('tac-extlinks')
    if (!footer || !host) return false
    const links = Array.prototype.filter.call(footer.querySelectorAll('a, button'), (e) => wanted(e.textContent))
    if (links.length < 3) return false // wait until all three exist
    const ref = document.querySelector('.tac-sidebar .tac-nav-item') // a real nav item, to copy its padding
    const pad = ref ? getComputedStyle(ref).padding : ''
    for (const e of links) { e.classList.add('tac-extlink'); if (pad) e.style.padding = pad; host.appendChild(e) }
    footer.style.display = 'none'
    return true
  }
  if (move()) return
  const o = new MutationObserver(() => { if (move()) o.disconnect() })
  o.observe(document.body, { childList: true, subtree: true })
}
relocateFooterLinks()

// Sidebar "⚙ Settings" item opens the SAME Gradio settings page as the footer link.
// Wrap sidebar.js's tacNavigate (already set, since that's a non-module script): the
// "Settings" nav target clicks Gradio's footer Settings button; everything else routes
// to its tab as before.
const _tacNav = window.tacNavigate
window.tacNavigate = function (target) {
  if (target === 'Settings') {
    const footer = document.querySelector('footer')
    const btn = footer && Array.prototype.find.call(
      footer.querySelectorAll('button, a'), (e) => /^settings$/i.test((e.textContent || '').trim()))
    if (btn) btn.click()
    return
  }
  if (_tacNav) _tacNav(target)
}

// ── Game tab (#battle-stage) — Forgotten Plains roam + on-map combat ──────────
// Drops a persona from the roster onto the map, lets it wander (A*), and fights enemies that aggro
// on proximity in real-time on the map. The old hardcoded 4v4 demo lived here; this is the shared
// comboBattler surface (auto-battler source, bundled to /web/comboBattler.js).
const sheetsOf = (c) => ({ idle: spriteUrl(c.idle), walk: spriteUrl(c.walk), attack: spriteUrl(c.attack), dmg: spriteUrl(c.dmg), die: spriteUrl(c.die) })
// Free-roam enemy rosters, one per biome band — the spawner draws from the roster of whichever land
// the spawn point lands on. Forgotten Plains = human patrols, Orc Kingdom = orcs, Necropolis = the
// Dark Brotherhood. (slug + combat stats, resolved against characters.json.)
const GAME_ROSTERS = {
  forgottenPlains: [
    { name: 'Swordsman', slug: 'rts-humans-swordsman', stats: { hp: 110, armor: 20, basicDamage: 12 }, attackType: 'melee' },
    { name: 'Spearman', slug: 'rts-humans-spearman', stats: { hp: 100, armor: 18, basicDamage: 11 }, attackType: 'melee' },
    { name: 'Knight', slug: 'rts-humans-knight', stats: { hp: 150, armor: 35, basicDamage: 14 }, attackType: 'melee' },
    { name: 'Archer', slug: 'rts-humans-archer', stats: { hp: 80, armor: 10, basicDamage: 12 }, attackType: 'ranged' },
  ],
  orc: [
    { name: 'Orc Blade', slug: 'dark-orc-army-orc-blade', stats: { hp: 130, armor: 25, basicDamage: 13 }, attackType: 'melee' },
    { name: 'Orc Raider', slug: 'dark-orc-army-orc-raider', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
    { name: 'Orc Scout', slug: 'dark-orc-army-orc-scout', stats: { hp: 90, armor: 12, basicDamage: 10 }, attackType: 'melee' },
    { name: 'Feral Berserker', slug: 'dark-orc-army-feral-berserker', stats: { hp: 160, armor: 30, basicDamage: 16 }, attackType: 'melee' },
    { name: 'Feral Arbalist', slug: 'dark-orc-army-feral-arbalist', stats: { hp: 95, armor: 14, basicDamage: 13 }, attackType: 'ranged' },
    { name: 'Cave Troll', slug: 'dark-orc-army-cave-troll', stats: { hp: 220, armor: 40, basicDamage: 20 }, attackType: 'melee' },
  ],
  necropolis: [
    { name: 'Acolyte', slug: 'dark-brotherhood-acolyte', stats: { hp: 100, armor: 15, basicDamage: 10 }, attackType: 'melee' },
    { name: 'Dark Cultist', slug: 'dark-brotherhood-dark-cultist', stats: { hp: 95, armor: 12, basicDamage: 12 }, attackType: 'ranged' },
    { name: 'Devoted Blade', slug: 'dark-brotherhood-devoted-blade', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
    { name: 'Dark Hound', slug: 'dark-brotherhood-dark-hound', stats: { hp: 85, armor: 10, basicDamage: 13 }, attackType: 'melee' },
    { name: 'Zealot', slug: 'dark-brotherhood-zealot', stats: { hp: 110, armor: 18, basicDamage: 13 }, attackType: 'melee' },
    { name: 'Dark Abomination', slug: 'dark-brotherhood-dark-abomination', stats: { hp: 200, armor: 35, basicDamage: 18 }, attackType: 'melee' },
  ],
}
// Persona class → engine profession (the engine has templates + skills for these five).
const PERSONA_PROF = { Warrior: 'Warrior', Ranger: 'Ranger', Monk: 'Monk', Assassin: 'Assassin', Mage: 'Necromancer', Paladin: 'Monk', Cleric: 'Monk', Knight: 'Warrior' }
const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player is this near
// A persona → controllable hero (class → sheets + engine profession).
const buildPlayer = (chars, p) => {
  const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
  return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
}
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)'
// Fill a square box with the hero's saved PORTRAIT (if any), else an animated idle GIF-style sprite
// of their class (same loop as the class selector). `sizePx` sizes the idle-sprite cell math.
async function fillAvatar(box, persona, chars, sizePx) {
  let blob = null
  try { if (persona?.id) blob = await getPortrait(persona.id) } catch { /* none */ }
  box.getAnimations?.().forEach((a) => a.cancel())
  if (blob) {
    box.style.backgroundImage = `url(${URL.createObjectURL(blob)})`
    box.style.backgroundSize = 'cover'; box.style.backgroundPosition = 'center'; box.style.imageRendering = 'auto'
  } else {
    box.style.imageRendering = 'pixelated'
    const pc = chars[CLASS_SLUG[persona?.unitClass]] || chars['true-heroes-iii-fighter']
    if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
  }
}
// Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
// onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
function openHeroDetail(host, persona, chars, onSelect) {
  const backdrop = document.createElement('div'); backdrop.className = 'hero-detail-backdrop'
  const card = document.createElement('div'); card.className = 'hero-detail'
  const portrait = document.createElement('div'); portrait.className = 'hero-detail-portrait'
  fillAvatar(portrait, persona, chars, 240)
  const info = document.createElement('div'); info.className = 'hero-detail-info'
  const name = document.createElement('div'); name.className = 'hero-detail-name'; name.textContent = persona?.name || 'Hero'
  info.append(name)
  if (persona?.unitClass) { const c = document.createElement('div'); c.className = 'hero-detail-class'; c.textContent = persona.unitClass; info.append(c) }
  if (persona?.about) { const a = document.createElement('div'); a.className = 'hero-detail-about'; a.textContent = persona.about; info.append(a) }
  if (persona?.quote) { const q = document.createElement('blockquote'); q.className = 'hero-detail-quote'; q.textContent = persona.quote; info.append(q) }
  const foot = document.createElement('div'); foot.className = 'hero-detail-foot'
  const back = document.createElement('button'); back.className = 'hero-detail-back'; back.type = 'button'; back.textContent = 'Back'
  const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
  foot.append(back, select)
  card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
  const close = () => backdrop.remove()
  back.addEventListener('click', close)
  backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
  select.addEventListener('click', () => { close(); onSelect() })
}
// Bottom-of-screen hero picker: a card per saved persona (idle-sprite avatar + name) plus a
// "+ Create hero" card. onPick gets the chosen persona; onCreate opens the create modal. Overlays
// the map (pointer-events only on the cards, so the map still pans).
function buildHeroPicker(host, personas, chars, onPick, onCreate) {
  const bar = document.createElement('div')
  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)'
  const title = document.createElement('div')
  title.textContent = 'Choose your hero'
  title.style.cssText = 'color:#e8e8e8;font-size:13px;letter-spacing:.08em;text-transform:uppercase;font-weight:600;text-shadow:0 1px 3px #000'
  const row = document.createElement('div')
  row.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap;justify-content:center;max-width:100%;pointer-events:auto'
  for (const p of personas) {
    const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
    const card = document.createElement('button')
    card.style.cssText = PICK_CARD_CSS
    const av = document.createElement('div')
    av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12 no-repeat;border:1px solid #20262e'
    fillAvatar(av, p, chars, 48) // portrait if saved, else animated class idle
    const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
    nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
    card.append(av, nm)
    if (p?.unitClass) { const cl = document.createElement('div'); cl.textContent = p.unitClass; cl.style.cssText = 'color:#8a93a0;font-weight:500;font-size:10px'; card.append(cl) }
    card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
    row.appendChild(card)
  }
  // "+ Create hero" card → opens the create modal.
  const create = document.createElement('button')
  create.className = 'hero-pick-create'; create.style.cssText = PICK_CARD_CSS
  const plus = document.createElement('div'); plus.className = 'hero-pick-plus'; plus.textContent = '+'
  plus.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center'
  const clbl = document.createElement('div'); clbl.textContent = 'Create hero'; clbl.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
  create.append(plus, clbl)
  create.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onCreate && onCreate() })
  row.appendChild(create)
  bar.append(title, row); host.appendChild(bar)
  return bar
}
// "Create a Hero" modal over the map: hosts the shared creator (heroCreator.js — stream details,
// generate a portrait + quote + voice, autosaved to the roster). onCreated(persona) fires on
// "Save & Play"; Cancel/✕/backdrop just close (a generated hero is already saved to the barracks).
function openCreateModal(host, onCreated) {
  const backdrop = document.createElement('div'); backdrop.className = 'hero-modal-backdrop'
  const card = document.createElement('div'); card.className = 'hero-modal'
  const head = document.createElement('div'); head.className = 'hero-modal-head'
  const title = document.createElement('div'); title.className = 'hero-modal-title'; title.textContent = 'Create a Hero'
  const x = document.createElement('button'); x.className = 'hero-modal-x'; x.type = 'button'; x.title = 'Close'; x.textContent = '✕'
  head.append(title, x)
  const body = document.createElement('div'); body.className = 'hero-modal-body'
  const foot = document.createElement('div'); foot.className = 'hero-modal-foot'
  const cancel = document.createElement('button'); cancel.className = 'hero-modal-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel'
  const save = document.createElement('button'); save.className = 'hero-modal-save'; save.type = 'button'; save.textContent = 'Save & Play ▶'; save.disabled = true
  foot.append(cancel, save) // the creator prepends its ← Back here (shown only once a hero exists)
  card.append(head, body, foot); backdrop.append(card); host.appendChild(backdrop)

  // Save & Play is enabled once a hero is saved; ← Back resets the creator → recruit state → disabled.
  const creator = mountHeroCreator(body, {
    showBarracks: true,
    backSlot: foot,
    onSaved: () => { save.disabled = false },
    onState: (s) => { title.textContent = s === 'portrait' ? 'Hero Details' : 'Create a Hero'; if (s === 'recruit') save.disabled = true },
  })
  const close = () => { try { creator.stop() } catch { /* ignore */ } backdrop.remove() }
  x.addEventListener('click', close)
  cancel.addEventListener('click', close)
  backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
  save.addEventListener('click', () => {
    const cur = creator.current(); if (!cur.persona) return
    close(); onCreated(cur.persona)
  })
}
whenEl('battle-stage', async (el) => {
  unprose(el)
  const chars = await loadChars()
  const buildRoster = (list) => list.map((e) => {
    const c = chars[e.slug]; if (!c) return null
    return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO } }
  }).filter(Boolean)
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
  // Mount with NO hero → the map shows and the player picks a persona from the bottom picker. The
  // picker reappears whenever there's no live hero (initial, and after the current one dies).
  comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
  await comboCtrl.ready
  const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
  const OVERVIEW_ZOOM = 0.45, GAMEPLAY_ZOOM = 2.5
  let picker = null
  // Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
  // the spawn point and drops them in there.
  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(() => {}) }
  const spawnWithFly = (p) => {
    picker?.remove(); picker = null
    const s = comboCtrl.getSpawnWorld()
    comboCtrl.selectHero(buildPlayer(chars, p))
    comboCtrl.map.flyTo(s.x, s.y, GAMEPLAY_ZOOM, 1000).catch(() => {})
  }
  // Picking a hero opens its detail page (portrait/about/quote + Select); Select confirms → spawnWithFly.
  const onPick = (p) => openHeroDetail(el, p, chars, () => spawnWithFly(p))
  const buildPicker = () => {
    const personas = listPersonas()
    return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
      () => openCreateModal(el, spawnWithFly))
  }
  const showPicker = () => { if (!picker) { picker = buildPicker(); flyToOverview() } }
  // Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
  // up in the "Choose your hero" bar without waiting for the next time it reopens.
  const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
  showPicker()
  comboCtrl.onChange((s) => { if (s.over) showPicker() })
  onRosterChange(refreshPicker)
})