tiny-army / web /tiny.js
polats's picture
Game: hero creation + selection flow; pin the Space
3ef6bd6
// 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)
})