// 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 { makeTeamBattle, step, FIELD } from '/web/engine.js' import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js' import { mountSpritePlayground } from '/web/playground.js' import { mountPersonaPanel } from '/web/personaPanel.js' import { mountDiaryPanel } from '/web/diaryPanel.js' import { mountSettingsPanel } from '/web/settingsPanel.js' 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 }) } // 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 } const loadSheet = async (url) => { const t = await PIXI.Assets.load(spriteUrl(url)); t.source.scaleMode = 'nearest'; return t } let playground = null, battleApp = null window.tinyResize = () => { try { battleApp && battleApp.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) => { const man = await fetch('/sprites/characters.json').then((r) => r.json()) playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl }) }) // ── 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) }) // 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() // 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) } // ── Battle tab (real sprites, reusing the engine + shared renderer) ────────── const PLAYERS = [ { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' }, { profession: 'Ranger', name: 'Sela', skills: [], slug: 'true-heroes-iii-ranger' }, { profession: 'Monk', name: 'Oda', skills: [], slug: 'true-heroes-ii-cleric' }, { profession: 'Assassin', name: 'Vex', skills: [], slug: 'true-heroes-iv-ninja-assassin' }, ] const ENEMIES = [ { name: 'Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-devoted-blade' }, { name: 'Acolyte', stats: { hp: 300, armor: 30, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-acolyte' }, { name: 'Blood Mage', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [], slug: 'true-heroes-iv-blood-mage' }, { name: 'Knight', stats: { hp: 360, armor: 40, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'rts-humans-knight' }, ] async function loadUnitSheets(slug) { const c = (await loadChars())[slug]; if (!c) return null const idle = await loadSheet(c.idle) const cell = cellOf(idle.source.height) const grid = async (u) => (u ? sliceGridWith(PIXI, await loadSheet(u), cell) : null) return { cell, idle: sliceGridWith(PIXI, idle, cell), walk: await grid(c.walk), die: await grid(c.die) } } whenEl('battle-stage', async (el) => { const app = new PIXI.Application() await app.init({ background: 0x0b0e12, resizeTo: el, antialias: false }) el.appendChild(app.canvas) battleApp = app const slugById = {} PLAYERS.forEach((p, i) => { slugById['P' + i] = p.slug }) ENEMIES.forEach((e, i) => { slugById['E' + i] = e.slug }) const sheets = {} for (const s of new Set(Object.values(slugById))) sheets[s] = await loadUnitSheets(s) const layer = new PIXI.Container(); layer.sortableChildren = true const bars = new PIXI.Graphics() app.stage.addChild(layer, bars) const view = {} let battle, seed = 1, overAt = 0 function fresh() { battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0 for (const id in view) { const v = view[id]; v.dead = false; v.state = null; v.facing = null; v.sp.onComplete = null; v.sp.alpha = 1 } } function ensureView(a) { if (view[a.id]) return view[a.id] const sh = sheets[slugById[a.id]] const sp = new PIXI.AnimatedSprite(sh ? rowFor(sh.idle, 'front-right') : [PIXI.Texture.WHITE]) sp.anchor.set(0.5, 0.9); sp.animationSpeed = ANIM.idle; sp.play(); layer.addChild(sp) return (view[a.id] = { sp, sh, state: null, facing: null, dead: false }) } fresh() app.ticker.add(() => { const W = app.screen.width, H = app.screen.height, sx = W / FIELD.w, sy = H / FIELD.h if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) } else if (!overAt) overAt = performance.now() if (overAt && performance.now() - overAt > 3000) fresh() bars.clear() const base = Math.min(Math.max(W / 190, 1.6), 3) for (const a of battle.actors) { const v = ensureView(a); if (!v.sh) continue const cx = a.x * sx, cy = a.y * sy const depth = base * (0.85 + 0.4 * (a.y / FIELD.h)) v.sp.position.set(cx, cy); v.sp.zIndex = a.y; v.sp.scale.set(depth) const facing = facingFor(a.faceX, a.faceY) if (!a.alive) { if (!v.dead) { v.dead = true; const f = rowFor(v.sh.die || v.sh.idle, facing) v.sp.loop = false; v.sp.textures = f; v.sp.animationSpeed = ANIM.die * 0.75; v.sp.alpha = 0.92 v.sp.onComplete = () => v.sp.gotoAndStop(f.length - 1); v.sp.gotoAndPlay(0) } continue } const mode = a.moving ? 'walk' : 'idle' if (v.state !== mode || v.facing !== facing) { v.state = mode; v.facing = facing v.sp.loop = true; v.sp.textures = rowFor(v.sh[mode] || v.sh.idle, facing) v.sp.animationSpeed = ANIM[mode]; v.sp.play() } const r = v.sh.cell * depth * 0.45, top = cy - v.sh.cell * depth - 4 const hp = Math.max(0, a.hp) / a.maxHp bars.rect(cx - r, top, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 }) bars.rect(cx - r, top, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e) } }) })