polats's picture
Move Voice settings into the Settings page; add a sidebar ⚙ Settings button
dffe06d
Raw
History Blame Contribute Delete
8.36 kB
// 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)
}
})
})