Spaces:
Running
Running
Battle tab: real sprites (shared slicer/facing/anim); fix die animation (idle-cell)
Browse filesDie/Death sheets are a single non-directional row — slice them with the idle-
derived cell (not their own height/4). Battle units now render as animated
sprites with facing + walk/idle/die, reusing src/render/spriteSheet.js.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/sheet.js +8 -0
- web/tiny.js +93 -48
web/sheet.js
CHANGED
|
@@ -12,9 +12,17 @@ var rowFramesWith = (pixi, texture, row = 0) => {
|
|
| 12 |
const g = sliceGridWith(pixi, texture);
|
| 13 |
return g[row] ?? g[0];
|
| 14 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
export {
|
|
|
|
|
|
|
| 16 |
SHEET_ROWS,
|
| 17 |
cellOf,
|
|
|
|
|
|
|
| 18 |
rowFramesWith,
|
| 19 |
sliceGridWith
|
| 20 |
};
|
|
|
|
| 12 |
const g = sliceGridWith(pixi, texture);
|
| 13 |
return g[row] ?? g[0];
|
| 14 |
};
|
| 15 |
+
var ANIM = { idle: 0.12, walk: 0.18, attack: 0.3, dmg: 0.25, die: 0.28 };
|
| 16 |
+
var ROW_FOR = { "front-right": 0, "front-left": 1, "back-right": 2, "back-left": 3 };
|
| 17 |
+
var rowFor = (grid, facing) => grid[ROW_FOR[facing]] ?? grid[0];
|
| 18 |
+
var facingFor = (faceX, faceY) => (faceY < 0 ? "back" : "front") + "-" + (faceX < 0 ? "left" : "right");
|
| 19 |
export {
|
| 20 |
+
ANIM,
|
| 21 |
+
ROW_FOR,
|
| 22 |
SHEET_ROWS,
|
| 23 |
cellOf,
|
| 24 |
+
facingFor,
|
| 25 |
+
rowFor,
|
| 26 |
rowFramesWith,
|
| 27 |
sliceGridWith
|
| 28 |
};
|
web/tiny.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
// Tiny Army — head module for the Gradio app. Renders Pixi into the gr.HTML
|
| 2 |
-
// canvas divs
|
| 3 |
-
//
|
| 4 |
-
//
|
|
|
|
| 5 |
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
|
| 6 |
import { makeTeamBattle, step, FIELD } from '/web/engine.js'
|
| 7 |
-
|
| 8 |
-
// (src/render/spriteSheet.js, esbuilt to /web/sheet.js).
|
| 9 |
-
import { sliceGridWith, cellOf } from '/web/sheet.js'
|
| 10 |
|
| 11 |
-
// Run cb when an element with `id` appears (Gradio renders after our head runs).
|
| 12 |
function whenEl(id, cb) {
|
| 13 |
const found = document.getElementById(id)
|
| 14 |
if (found && !found.dataset.tmounted) { found.dataset.tmounted = '1'; cb(found); return }
|
|
@@ -19,22 +17,10 @@ function whenEl(id, cb) {
|
|
| 19 |
o.observe(document.body, { childList: true, subtree: true })
|
| 20 |
}
|
| 21 |
|
| 22 |
-
//
|
| 23 |
-
//
|
| 24 |
-
let spriteApp = null, battleApp = null, lastSprite = null
|
| 25 |
-
window.tinyResize = () => {
|
| 26 |
-
try { battleApp && battleApp.resize() } catch {}
|
| 27 |
-
try { spriteApp && spriteApp.resize() } catch {}
|
| 28 |
-
// Re-render the sprite at the now-correct canvas size (it may have been first
|
| 29 |
-
// drawn while its tab was hidden at 0×0).
|
| 30 |
-
if (spriteApp && lastSprite) window.tinyShowSprite(...lastSprite)
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
// ── Sprite Animations tab ────────────────────────────────────────────────────
|
| 34 |
-
let charMap = null
|
| 35 |
-
// Sprite assets are served at /sprites (not /assets — that's Gradio's bundle).
|
| 36 |
-
// The manifest paths are authored as /assets/… so rewrite the prefix.
|
| 37 |
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/')
|
|
|
|
| 38 |
async function loadChars() {
|
| 39 |
if (charMap) return charMap
|
| 40 |
const d = await fetch('/sprites/characters.json').then((r) => r.json())
|
|
@@ -42,8 +28,18 @@ async function loadChars() {
|
|
| 42 |
for (const p of d.packs || []) for (const c of p.characters || []) charMap[c.slug] = c
|
| 43 |
return charMap
|
| 44 |
}
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
let pending = null
|
| 48 |
window.tinyShowSprite = (slug, anim) => { pending = [slug, anim] }
|
| 49 |
|
|
@@ -56,14 +52,17 @@ whenEl('sprite-stage', async (el) => {
|
|
| 56 |
window.tinyShowSprite = async (slug, anim) => {
|
| 57 |
const map = await loadChars()
|
| 58 |
const c = map[slug]; if (!c) return
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
if (sprite) { app.stage.removeChild(sprite); sprite.destroy() }
|
| 63 |
sprite = new PIXI.AnimatedSprite(frames)
|
| 64 |
-
sprite.anchor.set(0.5, 0.92); sprite.loop = true; sprite.animationSpeed = 0.12
|
| 65 |
-
|
| 66 |
-
sprite.scale.set(sc)
|
| 67 |
sprite.position.set(app.screen.width / 2, app.screen.height * 0.6)
|
| 68 |
app.stage.addChild(sprite); sprite.play()
|
| 69 |
lastSprite = [slug, anim]
|
|
@@ -71,40 +70,86 @@ whenEl('sprite-stage', async (el) => {
|
|
| 71 |
if (pending) window.tinyShowSprite(...pending)
|
| 72 |
})
|
| 73 |
|
| 74 |
-
// ── Battle tab (
|
| 75 |
const PLAYERS = [
|
| 76 |
-
{ profession: 'Warrior', name: 'Bram', skills: []
|
| 77 |
-
{ profession: '
|
|
|
|
|
|
|
| 78 |
]
|
| 79 |
const ENEMIES = [
|
| 80 |
-
{ name: '
|
| 81 |
-
{ name: '
|
| 82 |
-
{ name: '
|
| 83 |
-
{ name: '
|
| 84 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
whenEl('battle-stage', async (el) => {
|
| 86 |
const app = new PIXI.Application()
|
| 87 |
-
await app.init({ background: 0x0b0e12, resizeTo: el, antialias:
|
| 88 |
el.appendChild(app.canvas)
|
| 89 |
battleApp = app
|
| 90 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
let battle, seed = 1, overAt = 0
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
fresh()
|
| 94 |
app.ticker.add(() => {
|
| 95 |
const W = app.screen.width, H = app.screen.height, sx = W / FIELD.w, sy = H / FIELD.h
|
| 96 |
if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) }
|
| 97 |
else if (!overAt) overAt = performance.now()
|
| 98 |
-
if (overAt && performance.now() - overAt >
|
| 99 |
-
|
|
|
|
| 100 |
for (const a of battle.actors) {
|
| 101 |
-
const
|
| 102 |
-
const
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
const hp = Math.max(0, a.hp) / a.maxHp
|
| 106 |
-
|
| 107 |
-
|
| 108 |
}
|
| 109 |
})
|
| 110 |
})
|
|
|
|
| 1 |
// Tiny Army — head module for the Gradio app. Renders Pixi into the gr.HTML
|
| 2 |
+
// canvas divs (#battle-stage, #sprite-stage). The slicing + facing/anim
|
| 3 |
+
// convention come from the SAME shared source as the auto-battler
|
| 4 |
+
// (src/render/spriteSheet.js → /web/sheet.js). Pixi is injected (CDN) so there's
|
| 5 |
+
// one Pixi instance.
|
| 6 |
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
|
| 7 |
import { makeTeamBattle, step, FIELD } from '/web/engine.js'
|
| 8 |
+
import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js'
|
|
|
|
|
|
|
| 9 |
|
|
|
|
| 10 |
function whenEl(id, cb) {
|
| 11 |
const found = document.getElementById(id)
|
| 12 |
if (found && !found.dataset.tmounted) { found.dataset.tmounted = '1'; cb(found); return }
|
|
|
|
| 17 |
o.observe(document.body, { childList: true, subtree: true })
|
| 18 |
}
|
| 19 |
|
| 20 |
+
// Character manifest (slug → sheet URLs). Sheets are served at /sprites (not
|
| 21 |
+
// /assets — that's Gradio's bundle); the manifest paths are authored as /assets/…
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
const spriteUrl = (u) => (u || '').replace('/assets/', '/sprites/')
|
| 23 |
+
let charMap = null
|
| 24 |
async function loadChars() {
|
| 25 |
if (charMap) return charMap
|
| 26 |
const d = await fetch('/sprites/characters.json').then((r) => r.json())
|
|
|
|
| 28 |
for (const p of d.packs || []) for (const c of p.characters || []) charMap[c.slug] = c
|
| 29 |
return charMap
|
| 30 |
}
|
| 31 |
+
const loadSheet = async (url) => {
|
| 32 |
+
const t = await PIXI.Assets.load(spriteUrl(url)); t.source.scaleMode = 'nearest'; return t
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
let spriteApp = null, battleApp = null, lastSprite = null
|
| 36 |
+
window.tinyResize = () => {
|
| 37 |
+
try { battleApp && battleApp.resize() } catch {}
|
| 38 |
+
try { spriteApp && spriteApp.resize() } catch {}
|
| 39 |
+
if (spriteApp && lastSprite) window.tinyShowSprite(...lastSprite)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// ── Sprite Animations tab ────────────────────────────────────────────────────
|
| 43 |
let pending = null
|
| 44 |
window.tinyShowSprite = (slug, anim) => { pending = [slug, anim] }
|
| 45 |
|
|
|
|
| 52 |
window.tinyShowSprite = async (slug, anim) => {
|
| 53 |
const map = await loadChars()
|
| 54 |
const c = map[slug]; if (!c) return
|
| 55 |
+
// Cell comes from the IDLE sheet and is used for ALL sheets — Death sheets are
|
| 56 |
+
// a single non-directional row, so slicing them by their own height/4 breaks
|
| 57 |
+
// them (the old bug). Same rule the auto-battler uses.
|
| 58 |
+
const idleTex = await loadSheet(c.idle)
|
| 59 |
+
const cell = cellOf(idleTex.source.height)
|
| 60 |
+
const tex = await loadSheet(c[anim] || c.idle)
|
| 61 |
+
const frames = rowFor(sliceGridWith(PIXI, tex, cell), 'front-right')
|
| 62 |
if (sprite) { app.stage.removeChild(sprite); sprite.destroy() }
|
| 63 |
sprite = new PIXI.AnimatedSprite(frames)
|
| 64 |
+
sprite.anchor.set(0.5, 0.92); sprite.loop = true; sprite.animationSpeed = (ANIM[anim] ?? 0.12)
|
| 65 |
+
sprite.scale.set(Math.max(2, Math.min(app.screen.width, app.screen.height) / cell * 0.6))
|
|
|
|
| 66 |
sprite.position.set(app.screen.width / 2, app.screen.height * 0.6)
|
| 67 |
app.stage.addChild(sprite); sprite.play()
|
| 68 |
lastSprite = [slug, anim]
|
|
|
|
| 70 |
if (pending) window.tinyShowSprite(...pending)
|
| 71 |
})
|
| 72 |
|
| 73 |
+
// ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
|
| 74 |
const PLAYERS = [
|
| 75 |
+
{ profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },
|
| 76 |
+
{ profession: 'Ranger', name: 'Sela', skills: [], slug: 'true-heroes-iii-ranger' },
|
| 77 |
+
{ profession: 'Monk', name: 'Oda', skills: [], slug: 'true-heroes-ii-cleric' },
|
| 78 |
+
{ profession: 'Assassin', name: 'Vex', skills: [], slug: 'true-heroes-iv-ninja-assassin' },
|
| 79 |
]
|
| 80 |
const ENEMIES = [
|
| 81 |
+
{ name: 'Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-devoted-blade' },
|
| 82 |
+
{ name: 'Acolyte', stats: { hp: 300, armor: 30, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-acolyte' },
|
| 83 |
+
{ name: 'Blood Mage', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [], slug: 'true-heroes-iv-blood-mage' },
|
| 84 |
+
{ name: 'Knight', stats: { hp: 360, armor: 40, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'rts-humans-knight' },
|
| 85 |
]
|
| 86 |
+
|
| 87 |
+
async function loadUnitSheets(slug) {
|
| 88 |
+
const c = (await loadChars())[slug]; if (!c) return null
|
| 89 |
+
const idle = await loadSheet(c.idle)
|
| 90 |
+
const cell = cellOf(idle.source.height)
|
| 91 |
+
const grid = async (u) => (u ? sliceGridWith(PIXI, await loadSheet(u), cell) : null)
|
| 92 |
+
return { cell, idle: sliceGridWith(PIXI, idle, cell), walk: await grid(c.walk), die: await grid(c.die) }
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
whenEl('battle-stage', async (el) => {
|
| 96 |
const app = new PIXI.Application()
|
| 97 |
+
await app.init({ background: 0x0b0e12, resizeTo: el, antialias: false })
|
| 98 |
el.appendChild(app.canvas)
|
| 99 |
battleApp = app
|
| 100 |
+
const slugById = {}
|
| 101 |
+
PLAYERS.forEach((p, i) => { slugById['P' + i] = p.slug })
|
| 102 |
+
ENEMIES.forEach((e, i) => { slugById['E' + i] = e.slug })
|
| 103 |
+
const sheets = {}
|
| 104 |
+
for (const s of new Set(Object.values(slugById))) sheets[s] = await loadUnitSheets(s)
|
| 105 |
+
const layer = new PIXI.Container(); layer.sortableChildren = true
|
| 106 |
+
const bars = new PIXI.Graphics()
|
| 107 |
+
app.stage.addChild(layer, bars)
|
| 108 |
+
const view = {}
|
| 109 |
let battle, seed = 1, overAt = 0
|
| 110 |
+
function fresh() {
|
| 111 |
+
battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0
|
| 112 |
+
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 }
|
| 113 |
+
}
|
| 114 |
+
function ensureView(a) {
|
| 115 |
+
if (view[a.id]) return view[a.id]
|
| 116 |
+
const sh = sheets[slugById[a.id]]
|
| 117 |
+
const sp = new PIXI.AnimatedSprite(sh ? rowFor(sh.idle, 'front-right') : [PIXI.Texture.WHITE])
|
| 118 |
+
sp.anchor.set(0.5, 0.9); sp.animationSpeed = ANIM.idle; sp.play(); layer.addChild(sp)
|
| 119 |
+
return (view[a.id] = { sp, sh, state: null, facing: null, dead: false })
|
| 120 |
+
}
|
| 121 |
fresh()
|
| 122 |
app.ticker.add(() => {
|
| 123 |
const W = app.screen.width, H = app.screen.height, sx = W / FIELD.w, sy = H / FIELD.h
|
| 124 |
if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) }
|
| 125 |
else if (!overAt) overAt = performance.now()
|
| 126 |
+
if (overAt && performance.now() - overAt > 3000) fresh()
|
| 127 |
+
bars.clear()
|
| 128 |
+
const base = Math.min(Math.max(W / 190, 1.6), 3)
|
| 129 |
for (const a of battle.actors) {
|
| 130 |
+
const v = ensureView(a); if (!v.sh) continue
|
| 131 |
+
const cx = a.x * sx, cy = a.y * sy
|
| 132 |
+
const depth = base * (0.85 + 0.4 * (a.y / FIELD.h))
|
| 133 |
+
v.sp.position.set(cx, cy); v.sp.zIndex = a.y; v.sp.scale.set(depth)
|
| 134 |
+
const facing = facingFor(a.faceX, a.faceY)
|
| 135 |
+
if (!a.alive) {
|
| 136 |
+
if (!v.dead) {
|
| 137 |
+
v.dead = true; const f = rowFor(v.sh.die || v.sh.idle, facing)
|
| 138 |
+
v.sp.loop = false; v.sp.textures = f; v.sp.animationSpeed = ANIM.die * 0.75; v.sp.alpha = 0.92
|
| 139 |
+
v.sp.onComplete = () => v.sp.gotoAndStop(f.length - 1); v.sp.gotoAndPlay(0)
|
| 140 |
+
}
|
| 141 |
+
continue
|
| 142 |
+
}
|
| 143 |
+
const mode = a.moving ? 'walk' : 'idle'
|
| 144 |
+
if (v.state !== mode || v.facing !== facing) {
|
| 145 |
+
v.state = mode; v.facing = facing
|
| 146 |
+
v.sp.loop = true; v.sp.textures = rowFor(v.sh[mode] || v.sh.idle, facing)
|
| 147 |
+
v.sp.animationSpeed = ANIM[mode]; v.sp.play()
|
| 148 |
+
}
|
| 149 |
+
const r = v.sh.cell * depth * 0.45, top = cy - v.sh.cell * depth - 4
|
| 150 |
const hp = Math.max(0, a.hp) / a.maxHp
|
| 151 |
+
bars.rect(cx - r, top, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 })
|
| 152 |
+
bars.rect(cx - r, top, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e)
|
| 153 |
}
|
| 154 |
})
|
| 155 |
})
|