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