Spaces:
Running
Running
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)
})
|