polats Claude Opus 4.8 (1M context) commited on
Commit
4f0862a
·
1 Parent(s): 6a684b0

Battle tab: real sprites (shared slicer/facing/anim); fix die animation (idle-cell)

Browse files

Die/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>

Files changed (2) hide show
  1. web/sheet.js +8 -0
  2. 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 that gr.Blocks puts on the page (#battle-stage, #sprite-stage).
3
- // Gradio's gr.Dropdown/gr.Tabs drive it via `js=` handlers that call the globals
4
- // exposed here so the UI chrome is 100% Gradio, the rendering is Pixi.
 
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
- // Shared sprite-sheet slicer ONE source with the auto-battler
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
- // Pixi apps live in tabs that start hidden (0×0); Gradio fires a tab `select`
23
- // event wired to window.tinyResize so the canvas re-measures when shown.
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
- // Stub until the canvas mounts; remembers the last request so the initial
46
- // gr load() event isn't lost to a race.
 
 
 
 
 
 
 
 
 
 
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
- const url = spriteUrl(c[anim] || c.idle); if (!url) return
60
- const tex = await PIXI.Assets.load(url); tex.source.scaleMode = 'nearest'
61
- const frames = sliceGridWith(PIXI, tex)[0] // shared slicer, row 0 (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 = 0.12
65
- const sc = Math.max(2, Math.min(app.screen.width, app.screen.height) / cellOf(tex.source.height) * 0.6)
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 (reuses the deterministic engine) ─────────────────────────────
75
  const PLAYERS = [
76
- { profession: 'Warrior', name: 'Bram', skills: [] }, { profession: 'Ranger', name: 'Sela', skills: [] },
77
- { profession: 'Monk', name: 'Oda', skills: [] }, { profession: 'Assassin', name: 'Vex', skills: [] },
 
 
78
  ]
79
  const ENEMIES = [
80
- { name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
81
- { name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
82
- { name: 'Bog Shaman', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [] },
83
- { name: 'Dire Wolf', stats: { hp: 240, armor: 20, basicDamage: 14 }, attackType: 'melee', skills: [] },
84
  ]
 
 
 
 
 
 
 
 
 
85
  whenEl('battle-stage', async (el) => {
86
  const app = new PIXI.Application()
87
- await app.init({ background: 0x0b0e12, resizeTo: el, antialias: true })
88
  el.appendChild(app.canvas)
89
  battleApp = app
90
- const g = new PIXI.Graphics(); app.stage.addChild(g)
 
 
 
 
 
 
 
 
91
  let battle, seed = 1, overAt = 0
92
- const fresh = () => { battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0 }
 
 
 
 
 
 
 
 
 
 
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 > 2500) fresh()
99
- g.clear(); g.rect(0, 0, W, H).fill(0x10151b)
 
100
  for (const a of battle.actors) {
101
- const cx = a.x * sx, cy = a.y * sy, r = Math.max(6, (a.radius || 24) * sx)
102
- const col = a.team === 'player' ? 0x4ad6ff : 0xff5a4a
103
- if (!a.alive) { g.circle(cx, cy, r).fill({ color: col, alpha: 0.1 }); continue }
104
- g.circle(cx, cy, r).fill({ color: col, alpha: 0.85 }).stroke({ color: 0xffffff, width: 1, alpha: 0.5 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  const hp = Math.max(0, a.hp) / a.maxHp
106
- g.rect(cx - r, cy - r - 8, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 })
107
- g.rect(cx - r, cy - r - 8, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e)
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
  })