polats Claude Opus 4.8 (1M context) commited on
Commit
b450bdd
·
1 Parent(s): 500b804

Space: Sprite tab uses the shared spriteScene render core + click/tap-to-move

Browse files

The Sprite Animations tab now mounts the same framework-agnostic scene the React
app uses (auto-battler src/render/spriteScene.js, bundled to web/scene.js, Pixi
injected): directional walk, idle, one-shot actions, and NEW click/tap-to-move.
Character dropdown -> setCharacter; Attack/Hurt/Die buttons -> triggerAction;
desktop WASD gated so it never fights Gradio inputs. Adds build.sh to make the
engine/sheet/scene bundles reproducible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (6) hide show
  1. app.py +11 -7
  2. build.sh +16 -0
  3. web/engine.js +4 -4
  4. web/scene.js +463 -0
  5. web/sheet.js +1 -1
  6. web/tiny.js +39 -29
app.py CHANGED
@@ -21,7 +21,9 @@ WEB = os.path.join(HERE, "web")
21
  # Character dropdown choices from the (curated) manifest the frontend also reads.
22
  _manifest = json.load(open(os.path.join(WEB, "assets", "characters.json")))
23
  CHARACTERS = [(c["name"], c["slug"]) for p in _manifest["packs"] for c in p["characters"]]
24
- ANIMS = ["idle", "walk", "attack", "dmg", "die"]
 
 
25
 
26
  # NOTE: link sidebar.css here (head) rather than via mount's css_paths — Gradio
27
  # auto-scopes css_paths/css= selectors with a `.gradio-container .contain` prefix,
@@ -95,12 +97,14 @@ with gr.Blocks(title="Tiny Army") as demo:
95
  with gr.Tab("Battle") as battle_tab:
96
  gr.HTML(f'<div id="battle-stage" style="{STAGE}"></div>')
97
  with gr.Tab("Sprite Animations") as sprite_tab:
 
98
  with gr.Row():
99
- char = gr.Dropdown(CHARACTERS, value=CHARACTERS[0][1], label="Character")
100
- anim = gr.Dropdown(ANIMS, value="idle", label="Animation")
101
  gr.HTML(f'<div id="sprite-stage" style="{STAGE.replace("56vh", "48vh")}"></div>')
102
- char.change(None, [char, anim], None, js="(c,a)=>window.tinyShowSprite(c,a)")
103
- anim.change(None, [char, anim], None, js="(c,a)=>window.tinyShowSprite(c,a)")
 
104
  # Pixi canvases start hidden (0×0); re-measure them when a tab is shown.
105
  battle_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
106
  sprite_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
@@ -110,8 +114,8 @@ with gr.Blocks(title="Tiny Army") as demo:
110
  traits = gr.Textbox("Cautious, Veteran, Vengeful", label="Traits")
111
  out = gr.Textbox(label="War diary", lines=6)
112
  gr.Button("Write diary", variant="primary").click(diary, [unit, traits], out)
113
- # Render the initial sprite once the page (and the canvas) are up.
114
- demo.load(None, [char, anim], None, js="(c,a)=>{window.tinyShowSprite&&window.tinyShowSprite(c,a)}")
115
 
116
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
117
  fastapi_app = FastAPI()
 
21
  # Character dropdown choices from the (curated) manifest the frontend also reads.
22
  _manifest = json.load(open(os.path.join(WEB, "assets", "characters.json")))
23
  CHARACTERS = [(c["name"], c["slug"]) for p in _manifest["packs"] for c in p["characters"]]
24
+ # One-shot action buttons on the Sprite tab → scene.triggerAction(state). No-op if
25
+ # the character lacks that sheet, so a single static set is safe for every char.
26
+ ACTIONS = [("Attack", "attack"), ("Hurt", "dmg"), ("Die", "die")]
27
 
28
  # NOTE: link sidebar.css here (head) rather than via mount's css_paths — Gradio
29
  # auto-scopes css_paths/css= selectors with a `.gradio-container .contain` prefix,
 
97
  with gr.Tab("Battle") as battle_tab:
98
  gr.HTML(f'<div id="battle-stage" style="{STAGE}"></div>')
99
  with gr.Tab("Sprite Animations") as sprite_tab:
100
+ char = gr.Dropdown(CHARACTERS, value=CHARACTERS[0][1], label="Character")
101
  with gr.Row():
102
+ for _label, _state in ACTIONS:
103
+ gr.Button(_label, size="sm").click(None, None, None, js=f"()=>window.tinyAction('{_state}')")
104
  gr.HTML(f'<div id="sprite-stage" style="{STAGE.replace("56vh", "48vh")}"></div>')
105
+ gr.Markdown("*Tap / click the stage to walk there · **WASD** to move · "
106
+ "buttons play one-shot actions.*")
107
+ char.change(None, [char], None, js="(c)=>window.tinySetChar(c)")
108
  # Pixi canvases start hidden (0×0); re-measure them when a tab is shown.
109
  battle_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
110
  sprite_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
 
114
  traits = gr.Textbox("Cautious, Veteran, Vengeful", label="Traits")
115
  out = gr.Textbox(label="War diary", lines=6)
116
  gr.Button("Write diary", variant="primary").click(diary, [unit, traits], out)
117
+ # Load the initial character once the page (and the canvas) are up.
118
+ demo.load(None, [char], None, js="(c)=>{window.tinySetChar&&window.tinySetChar(c)}")
119
 
120
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
121
  fastapi_app = FastAPI()
build.sh ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Bundle the shared, framework-agnostic JS modules from the auto-battler repo into
3
+ # this Space's web/ dir. Pixi is INJECTED by the caller (web/tiny.js imports it
4
+ # from a CDN and passes it in), so none of these modules import Pixi — nothing to
5
+ # mark external. Run from the tiny-army dir with ../auto-battler alongside.
6
+ #
7
+ # ./build.sh # uses ../auto-battler
8
+ # AB=/path ./build.sh # override the source repo
9
+ set -euo pipefail
10
+ AB="${AB:-../auto-battler}"
11
+
12
+ npx --yes esbuild "$AB/src/engine/teamBattle.js" --bundle --format=esm --outfile=web/engine.js
13
+ npx --yes esbuild "$AB/src/render/spriteSheet.js" --bundle --format=esm --outfile=web/sheet.js
14
+ npx --yes esbuild "$AB/src/render/spriteScene.js" --bundle --format=esm --outfile=web/scene.js
15
+
16
+ echo "bundled web/{engine,sheet,scene}.js from $AB"
web/engine.js CHANGED
@@ -1,4 +1,4 @@
1
- // src/engine/skills.js
2
  var FIRST_15 = [
3
  // ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ──
4
  {
@@ -705,7 +705,7 @@ var VARIANT_EXTRA = [
705
  ];
706
  var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA];
707
 
708
- // src/engine/rng.js
709
  function makeRng(seed) {
710
  let a = seed >>> 0;
711
  return function rng() {
@@ -717,12 +717,12 @@ function makeRng(seed) {
717
  };
718
  }
719
 
720
- // src/engine/range.js
721
  var MELEE_GW = 144;
722
  var BASIC_MELEE_GW = MELEE_GW / 2;
723
  var BOW_GW = 1e3;
724
 
725
- // src/engine/teamBattle.js
726
  var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s]));
727
  var skillById = (id) => byId[id] || null;
728
  var FIELD = { w: 1e3, h: 600 };
 
1
+ // ../auto-battler/src/engine/skills.js
2
  var FIRST_15 = [
3
  // ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ──
4
  {
 
705
  ];
706
  var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA];
707
 
708
+ // ../auto-battler/src/engine/rng.js
709
  function makeRng(seed) {
710
  let a = seed >>> 0;
711
  return function rng() {
 
717
  };
718
  }
719
 
720
+ // ../auto-battler/src/engine/range.js
721
  var MELEE_GW = 144;
722
  var BASIC_MELEE_GW = MELEE_GW / 2;
723
  var BOW_GW = 1e3;
724
 
725
+ // ../auto-battler/src/engine/teamBattle.js
726
  var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s]));
727
  var skillById = (id) => byId[id] || null;
728
  var FIELD = { w: 1e3, h: 600 };
web/scene.js ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ../auto-battler/src/render/spriteSheet.js
2
+ var SHEET_ROWS = 4;
3
+ var cellOf = (height) => Math.round(height / SHEET_ROWS);
4
+ function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) {
5
+ const { Texture, Rectangle } = pixi;
6
+ const src = texture.source;
7
+ const rows = Math.max(1, Math.round(src.height / cell));
8
+ const cols = Math.max(1, Math.round(src.width / cell));
9
+ return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_2, c) => new Texture({ source: src, frame: new Rectangle(c * cell, r * cell, cell, cell) })));
10
+ }
11
+ var ROW_FOR = { "front-right": 0, "front-left": 1, "back-right": 2, "back-left": 3 };
12
+ var rowFor = (grid, facing) => grid[ROW_FOR[facing]] ?? grid[0];
13
+
14
+ // ../auto-battler/src/render/spriteScene.js
15
+ var SPEED = 3;
16
+ var PROJ_SPEED = 5;
17
+ var SCALE = 4;
18
+ var ROWS = 4;
19
+ var ARRIVE = SPEED;
20
+ var ANIM_SPEED = { idle: 0.12, walk: 0.18, attack: 0.22, dmg: 0.2, die: 0.16, jump: 0.2 };
21
+ var SCENE_ACTIONS = [
22
+ { state: "attack", code: "Space", label: "Space", verb: "attack" },
23
+ { state: "dmg", code: "KeyH", label: "H", verb: "hurt" },
24
+ { state: "die", code: "KeyK", label: "K", verb: "die" },
25
+ { state: "jump", code: "KeyJ", label: "J", verb: "jump" }
26
+ ];
27
+ var STATE_KEYS = ["idle", "walk", ...SCENE_ACTIONS.map((a) => a.state), "attackDiagonal"];
28
+ var orthoRow = (d) => d.x > 0 ? 0 : d.x < 0 ? 1 : d.y > 0 ? 2 : 3;
29
+ var diagRow = (d) => d.y > 0 ? d.x > 0 ? 0 : 1 : d.x > 0 ? 2 : 3;
30
+ var usesAimedAttack = (c) => c?.attackVerb === "shoot" || !!c?.attackOrtho;
31
+ function createSpriteScene(pixi, host, opts = {}) {
32
+ const { Application, Assets, AnimatedSprite, Graphics } = pixi;
33
+ const urlFor = opts.urlFor || ((u) => u);
34
+ const anim = { ...ANIM_SPEED, ...opts.anim || {} };
35
+ const sliceGrid = (texture, cell) => sliceGridWith(pixi, texture, cell);
36
+ let app = null;
37
+ let sprite = null;
38
+ let shadow = null;
39
+ let overlay = null;
40
+ let frames = null;
41
+ let keys = { x: 0, y: 0 };
42
+ let moveTarget = null;
43
+ let action = null;
44
+ let pendingAction = null;
45
+ let dieHold = false;
46
+ let side = "right";
47
+ let depth = "front";
48
+ let dir = { x: 1, y: 1 };
49
+ let shoot = false;
50
+ let applied = "";
51
+ let extras = [];
52
+ let effectsOn = true;
53
+ let shadowsOn = true;
54
+ let flying = [];
55
+ let marker = null;
56
+ let markerLife = 0;
57
+ let currentKind = "idle";
58
+ let lastAimKey = "";
59
+ let changeCb = null;
60
+ let destroyed = false;
61
+ function snapshot() {
62
+ return {
63
+ kind: currentKind,
64
+ facing: `${depth}-${side}`,
65
+ aim: `${dir.x},${dir.y}`,
66
+ shoot,
67
+ moving: !!moveTarget || keys.x !== 0 || keys.y !== 0,
68
+ x: sprite ? sprite.x : null,
69
+ y: sprite ? sprite.y : null,
70
+ toggles: { effects: effectsOn, shadows: shadowsOn }
71
+ };
72
+ }
73
+ function emit() {
74
+ if (changeCb) changeCb(snapshot());
75
+ }
76
+ const ready = (async () => {
77
+ const a = new Application();
78
+ await a.init({ background: 15524556, antialias: false, resizeTo: host });
79
+ if (destroyed) {
80
+ a.destroy(true, { children: true });
81
+ return;
82
+ }
83
+ app = a;
84
+ app.canvas.setAttribute("data-testid", "pixi-canvas");
85
+ app.canvas.style.touchAction = "none";
86
+ app.canvas.addEventListener("pointerdown", onPointerDown);
87
+ host.appendChild(app.canvas);
88
+ app.ticker.add(tick);
89
+ })();
90
+ function onPointerDown(e) {
91
+ if (!app) return;
92
+ const rect = app.canvas.getBoundingClientRect();
93
+ if (!rect.width || !rect.height) return;
94
+ const x = (e.clientX - rect.left) / rect.width * app.screen.width;
95
+ const y = (e.clientY - rect.top) / rect.height * app.screen.height;
96
+ moveTo(x, y);
97
+ }
98
+ function moveTo(x, y) {
99
+ if (!app) return;
100
+ const tx = Math.max(0, Math.min(app.screen.width, x));
101
+ const ty = Math.max(0, Math.min(app.screen.height, y));
102
+ moveTarget = { x: tx, y: ty };
103
+ if (Graphics) {
104
+ if (!marker) {
105
+ marker = new Graphics();
106
+ app.stage.addChildAt(marker, 0);
107
+ }
108
+ marker.clear();
109
+ marker.circle(0, 0, 11).stroke({ color: 7035903, width: 2, alpha: 1 });
110
+ marker.position.set(tx, ty);
111
+ marker.alpha = 0.9;
112
+ markerLife = 30;
113
+ }
114
+ }
115
+ function tick(ticker) {
116
+ const s = sprite;
117
+ const f = frames;
118
+ if (!s || !f) return;
119
+ let vx = keys.x;
120
+ let vy = keys.y;
121
+ let usingTarget = false;
122
+ let seekDX = 0;
123
+ let seekDY = 0;
124
+ if (vx === 0 && vy === 0 && moveTarget) {
125
+ seekDX = moveTarget.x - s.x;
126
+ seekDY = moveTarget.y - s.y;
127
+ const d = Math.hypot(seekDX, seekDY);
128
+ if (d <= ARRIVE) {
129
+ s.x = moveTarget.x;
130
+ s.y = moveTarget.y;
131
+ moveTarget = null;
132
+ } else {
133
+ vx = seekDX;
134
+ vy = seekDY;
135
+ usingTarget = true;
136
+ }
137
+ }
138
+ const wantMove = vx !== 0 || vy !== 0;
139
+ if (pendingAction) {
140
+ const pend = pendingAction;
141
+ pendingAction = null;
142
+ if (f[pend] && (action === null || dieHold)) {
143
+ action = pend;
144
+ dieHold = false;
145
+ applied = "";
146
+ }
147
+ }
148
+ if (dieHold && wantMove) {
149
+ action = null;
150
+ dieHold = false;
151
+ }
152
+ const acting = action !== null;
153
+ if (!acting && wantMove) {
154
+ let sgx, sgy;
155
+ if (usingTarget) {
156
+ sgx = Math.abs(seekDX) > 4 ? Math.sign(seekDX) : 0;
157
+ sgy = Math.abs(seekDY) > 4 ? Math.sign(seekDY) : 0;
158
+ } else {
159
+ sgx = Math.sign(vx);
160
+ sgy = Math.sign(vy);
161
+ }
162
+ if (sgx > 0) side = "right";
163
+ else if (sgx < 0) side = "left";
164
+ if (sgy > 0) depth = "front";
165
+ else if (sgy < 0) depth = "back";
166
+ if (sgx || sgy) dir = { x: sgx, y: sgy };
167
+ const len = Math.hypot(vx, vy) || 1;
168
+ const step = SPEED * ticker.deltaTime;
169
+ s.x += vx / len * step;
170
+ s.y += vy / len * step;
171
+ const halfW = s.width / 2;
172
+ const halfH = s.height / 2;
173
+ s.x = Math.max(halfW, Math.min(app.screen.width - halfW, s.x));
174
+ s.y = Math.max(halfH, Math.min(app.screen.height - halfH, s.y));
175
+ }
176
+ const aimKey = `${dir.x},${dir.y}`;
177
+ if (aimKey !== lastAimKey) {
178
+ lastAimKey = aimKey;
179
+ emit();
180
+ }
181
+ if (shadow) {
182
+ shadow.x = s.x;
183
+ shadow.y = s.y;
184
+ }
185
+ if (overlay?.visible) {
186
+ overlay.x = s.x;
187
+ overlay.y = s.y;
188
+ }
189
+ if (marker && markerLife > 0) {
190
+ markerLife -= ticker.deltaTime;
191
+ const t = Math.max(0, markerLife / 30);
192
+ marker.alpha = t * 0.9;
193
+ marker.scale.set(1 + (1 - t) * 0.6);
194
+ }
195
+ for (let i = flying.length - 1; i >= 0; i--) {
196
+ const fl = flying[i];
197
+ fl.sprite.x += fl.dx * PROJ_SPEED * ticker.deltaTime;
198
+ fl.sprite.y += fl.dy * PROJ_SPEED * ticker.deltaTime;
199
+ if (!fl.impFired && fl.impGrid) {
200
+ const dist = Math.hypot(fl.sprite.x - fl.startX, fl.sprite.y - fl.startY);
201
+ if (dist > 150) {
202
+ fl.impFired = true;
203
+ const imp = new AnimatedSprite(fl.impGrid[0]);
204
+ imp.anchor.set(0.5);
205
+ imp.scale.set(SCALE);
206
+ imp.loop = false;
207
+ imp.animationSpeed = anim.attack;
208
+ imp.x = fl.sprite.x;
209
+ imp.y = fl.sprite.y;
210
+ imp.onComplete = () => {
211
+ if (imp.parent) imp.parent.removeChild(imp);
212
+ imp.destroy();
213
+ };
214
+ app.stage.addChild(imp);
215
+ imp.gotoAndPlay(0);
216
+ }
217
+ }
218
+ const os = fl.sprite.x < -128 || fl.sprite.x > app.screen.width + 128 || fl.sprite.y < -128 || fl.sprite.y > app.screen.height + 128;
219
+ if (os) {
220
+ if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite);
221
+ fl.sprite.destroy();
222
+ flying.splice(i, 1);
223
+ }
224
+ }
225
+ const kind = acting ? action : wantMove ? "walk" : "idle";
226
+ currentKind = kind;
227
+ let fr, key;
228
+ if (kind === "attack" && shoot) {
229
+ const d = dir;
230
+ const useDiag = d.x !== 0 && d.y !== 0 && !!f.attackDiagonal;
231
+ const grid = useDiag ? f.attackDiagonal : f.attack;
232
+ const row = useDiag ? diagRow(d) : orthoRow(d);
233
+ fr = grid[row] ?? grid[0];
234
+ key = `shoot:${useDiag ? "d" : "o"}${row}`;
235
+ } else {
236
+ const facing = `${depth}-${side}`;
237
+ fr = rowFor(f[kind], facing);
238
+ key = `${kind}:${facing}`;
239
+ }
240
+ if (key !== applied) {
241
+ applied = key;
242
+ const oneShot = kind !== "idle" && kind !== "walk";
243
+ s.textures = fr;
244
+ s.loop = !oneShot;
245
+ s.animationSpeed = anim[kind] ?? (oneShot ? anim.attack : anim.walk);
246
+ s.gotoAndPlay(0);
247
+ const actionKey = action ?? kind;
248
+ const facing = `${depth}-${side}`;
249
+ if (shadow) {
250
+ let shadGrid;
251
+ if (kind === "attack" && shoot) {
252
+ const d = dir;
253
+ const useDiag = d.x !== 0 && d.y !== 0 && !!f["shd:attackDiagonal"];
254
+ shadGrid = useDiag ? f["shd:attackDiagonal"] : f["shd:attack"];
255
+ if (shadGrid) {
256
+ const row = useDiag ? diagRow(d) : orthoRow(d);
257
+ shadow.textures = shadGrid[row] ?? shadGrid[0];
258
+ }
259
+ } else {
260
+ shadGrid = f["shd:" + actionKey];
261
+ if (shadGrid) shadow.textures = rowFor(shadGrid, facing);
262
+ }
263
+ if (shadGrid && shadowsOn) {
264
+ shadow.loop = s.loop;
265
+ shadow.animationSpeed = s.animationSpeed;
266
+ shadow.visible = true;
267
+ shadow.gotoAndPlay(0);
268
+ } else {
269
+ shadow.visible = false;
270
+ }
271
+ }
272
+ if (overlay) {
273
+ const effGrid = effectsOn && f["eff:" + actionKey];
274
+ if (effGrid && oneShot) {
275
+ overlay.textures = rowFor(effGrid, facing);
276
+ overlay.loop = false;
277
+ overlay.animationSpeed = s.animationSpeed;
278
+ overlay.x = s.x;
279
+ overlay.y = s.y;
280
+ overlay.visible = true;
281
+ overlay.gotoAndPlay(0);
282
+ } else {
283
+ overlay.visible = false;
284
+ }
285
+ }
286
+ if (oneShot && effectsOn) {
287
+ const projGrid = f["proj:" + actionKey];
288
+ if (projGrid) {
289
+ const d = dir;
290
+ const row = orthoRow(d);
291
+ const proj = new AnimatedSprite(projGrid[row] ?? projGrid[0]);
292
+ proj.anchor.set(0.5);
293
+ proj.scale.set(SCALE);
294
+ proj.loop = true;
295
+ proj.animationSpeed = anim.attack;
296
+ proj.x = s.x;
297
+ proj.y = s.y;
298
+ proj.gotoAndPlay(0);
299
+ app.stage.addChild(proj);
300
+ const len = Math.hypot(d.x, d.y) || 1;
301
+ flying.push({
302
+ sprite: proj,
303
+ dx: d.x / len,
304
+ dy: d.y / len,
305
+ startX: s.x,
306
+ startY: s.y,
307
+ impGrid: f["imp:" + actionKey] ?? null,
308
+ impFired: false
309
+ });
310
+ }
311
+ }
312
+ }
313
+ }
314
+ async function setCharacter(active) {
315
+ await ready;
316
+ if (!app || !active) return;
317
+ const sheets = [
318
+ ...STATE_KEYS.filter((k) => active[k]).map((k) => ({ gridKey: k, url: active[k], type: "body" })),
319
+ ...(active.extras ?? []).map((e) => ({ gridKey: "x:" + e.key, url: e.url, type: "body" })),
320
+ ...active.attackEffect ? [{ gridKey: "eff:attack", url: active.attackEffect, type: "eff" }] : [],
321
+ ...active.attackProjectile ? [{ gridKey: "proj:attack", url: active.attackProjectile, type: "proj" }] : [],
322
+ ...active.attackImpact ? [{ gridKey: "imp:attack", url: active.attackImpact, type: "imp" }] : [],
323
+ ...Object.entries(active.shadows ?? {}).map(([k, url]) => ({ gridKey: "shd:" + k, url, type: "body" })),
324
+ ...(active.extras ?? []).flatMap((e) => [
325
+ e.effect ? { gridKey: "eff:x:" + e.key, url: e.effect, type: "eff" } : null,
326
+ e.projectile ? { gridKey: "proj:x:" + e.key, url: e.projectile, type: "proj" } : null,
327
+ e.impact ? { gridKey: "imp:x:" + e.key, url: e.impact, type: "imp" } : null,
328
+ e.shadow ? { gridKey: "shd:x:" + e.key, url: e.shadow, type: "body" } : null
329
+ ].filter(Boolean))
330
+ ];
331
+ const texs = await Promise.all(sheets.map((s) => Assets.load(urlFor(s.url))));
332
+ if (!app) return;
333
+ const grids = {};
334
+ const idleIdx = sheets.findIndex((s) => s.gridKey === "idle");
335
+ const cell = cellOf(texs[idleIdx].source.height);
336
+ sheets.forEach((s, i) => {
337
+ texs[i].source.scaleMode = "nearest";
338
+ if (s.type === "proj") grids[s.gridKey] = sliceGrid(texs[i], Math.max(1, Math.round(texs[i].source.height / ROWS)));
339
+ else if (s.type === "imp") grids[s.gridKey] = sliceGrid(texs[i], Math.max(1, texs[i].source.height));
340
+ else grids[s.gridKey] = sliceGrid(texs[i], cell);
341
+ });
342
+ frames = grids;
343
+ extras = active.extras ?? [];
344
+ shoot = usesAimedAttack(active);
345
+ action = null;
346
+ pendingAction = null;
347
+ dieHold = false;
348
+ applied = "";
349
+ lastAimKey = "";
350
+ const startFrames = rowFor(grids.idle, `${depth}-${side}`);
351
+ if (!sprite) {
352
+ const shd = new AnimatedSprite(startFrames);
353
+ shd.anchor.set(0.5);
354
+ shd.scale.set(SCALE);
355
+ shd.loop = true;
356
+ shd.visible = false;
357
+ shd.animationSpeed = anim.idle;
358
+ shd.x = app.screen.width / 2;
359
+ shd.y = app.screen.height / 2;
360
+ app.stage.addChild(shd);
361
+ shadow = shd;
362
+ const s = new AnimatedSprite(startFrames);
363
+ s.anchor.set(0.5);
364
+ s.scale.set(SCALE);
365
+ s.loop = true;
366
+ s.animationSpeed = anim.idle;
367
+ s.x = app.screen.width / 2;
368
+ s.y = app.screen.height / 2;
369
+ s.onComplete = () => {
370
+ if (action === "die") dieHold = true;
371
+ else action = null;
372
+ };
373
+ app.stage.addChild(s);
374
+ s.gotoAndPlay(0);
375
+ sprite = s;
376
+ const ov = new AnimatedSprite(startFrames);
377
+ ov.anchor.set(0.5);
378
+ ov.scale.set(SCALE);
379
+ ov.loop = false;
380
+ ov.visible = false;
381
+ ov.animationSpeed = anim.attack;
382
+ ov.onComplete = () => {
383
+ ov.visible = false;
384
+ };
385
+ app.stage.addChild(ov);
386
+ overlay = ov;
387
+ } else {
388
+ const s = sprite;
389
+ s.loop = true;
390
+ s.textures = startFrames;
391
+ s.animationSpeed = anim.idle;
392
+ s.gotoAndPlay(0);
393
+ if (shadow) shadow.visible = false;
394
+ if (overlay) overlay.visible = false;
395
+ for (const fl of flying) {
396
+ if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite);
397
+ fl.sprite.destroy();
398
+ }
399
+ flying = [];
400
+ }
401
+ emit();
402
+ }
403
+ function setVelocity(v) {
404
+ keys = { x: v?.x || 0, y: v?.y || 0 };
405
+ if (keys.x || keys.y) moveTarget = null;
406
+ }
407
+ function triggerAction(stateKey) {
408
+ if (stateKey) pendingAction = stateKey;
409
+ }
410
+ function setToggles(t) {
411
+ if (t && "effects" in t) effectsOn = !!t.effects;
412
+ if (t && "shadows" in t) {
413
+ shadowsOn = !!t.shadows;
414
+ if (shadow && !shadowsOn) shadow.visible = false;
415
+ }
416
+ emit();
417
+ }
418
+ return {
419
+ ready,
420
+ setCharacter,
421
+ setVelocity,
422
+ triggerAction,
423
+ moveTo,
424
+ setToggles,
425
+ getSnapshot: snapshot,
426
+ onChange: (cb) => {
427
+ changeCb = cb;
428
+ },
429
+ resize: () => {
430
+ if (app) app.resize();
431
+ },
432
+ // Re-place the character at the centre of the current canvas — used by hosts
433
+ // that mount the stage in a hidden/0-size tab and reveal it later (the Space).
434
+ recenter: () => {
435
+ if (!app || !sprite) return;
436
+ sprite.x = app.screen.width / 2;
437
+ sprite.y = app.screen.height / 2;
438
+ if (shadow) {
439
+ shadow.x = sprite.x;
440
+ shadow.y = sprite.y;
441
+ }
442
+ moveTarget = null;
443
+ },
444
+ destroy: () => {
445
+ destroyed = true;
446
+ const a = app;
447
+ app = null;
448
+ if (a) {
449
+ a.canvas.removeEventListener("pointerdown", onPointerDown);
450
+ a.destroy(true, { children: true });
451
+ }
452
+ sprite = shadow = overlay = frames = marker = null;
453
+ flying = [];
454
+ }
455
+ };
456
+ }
457
+ export {
458
+ SCENE_ACTIONS,
459
+ createSpriteScene,
460
+ diagRow,
461
+ orthoRow,
462
+ usesAimedAttack
463
+ };
web/sheet.js CHANGED
@@ -1,4 +1,4 @@
1
- // src/render/spriteSheet.js
2
  var SHEET_ROWS = 4;
3
  var cellOf = (height) => Math.round(height / SHEET_ROWS);
4
  function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) {
 
1
+ // ../auto-battler/src/render/spriteSheet.js
2
  var SHEET_ROWS = 4;
3
  var cellOf = (height) => Math.round(height / SHEET_ROWS);
4
  function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) {
web/tiny.js CHANGED
@@ -6,6 +6,7 @@
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)
@@ -32,42 +33,51 @@ 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
 
46
  whenEl('sprite-stage', async (el) => {
47
- const app = new PIXI.Application()
48
- await app.init({ background: 0x10151b, resizeTo: el, antialias: false })
49
- el.appendChild(app.canvas)
50
- spriteApp = app
51
- let sprite = null
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]
69
  }
70
- if (pending) window.tinyShowSprite(...pending)
 
 
71
  })
72
 
73
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
 
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
+ import { createSpriteScene } from '/web/scene.js'
10
 
11
  function whenEl(id, cb) {
12
  const found = document.getElementById(id)
 
33
  const t = await PIXI.Assets.load(spriteUrl(url)); t.source.scaleMode = 'nearest'; return t
34
  }
35
 
36
+ let scene = null, battleApp = null, pendingChar = null
37
  window.tinyResize = () => {
38
  try { battleApp && battleApp.resize() } catch {}
39
+ // Re-fit + re-centre: the sprite stage mounts in a hidden (0-size) tab, so the
40
+ // character must be re-placed once the tab is actually shown.
41
+ try { if (scene) { scene.resize(); scene.recenter() } } catch {}
42
  }
43
 
44
+ // ── Sprite Animations tab — shared render core ───────────────────────────────
45
+ // The framework-agnostic scene (auto-battler src/render/spriteScene.js → /web/
46
+ // scene.js, Pixi injected) drives the SAME canvas the React app uses: directional
47
+ // walk, idle, one-shot actions, and click/tap-to-move. The Gradio chrome only
48
+ // switches characters (dropdown) and fires actions (buttons) through it.
49
+ window.tinySetChar = async (slug) => {
50
+ pendingChar = slug
51
+ if (!scene) return
52
+ const c = (await loadChars())[slug]
53
+ if (c) scene.setCharacter(c)
54
+ }
55
+ // Back-compat: app.py's load hook may still call tinyShowSprite — treat as char.
56
+ window.tinyShowSprite = (slug) => window.tinySetChar(slug)
57
+ window.tinyAction = (state) => { if (scene) scene.triggerAction(state) }
58
 
59
  whenEl('sprite-stage', async (el) => {
60
+ scene = createSpriteScene(PIXI, el, { urlFor: spriteUrl })
61
+ await scene.ready
62
+ // Desktop keyboard (WASD/arrows). Ignored while a form field is focused or the
63
+ // stage is hidden, so it never fights Gradio's own inputs on other tabs.
64
+ const held = { x: new Set(), y: new Set() }
65
+ const apply = () => scene.setVelocity({ x: held.x.has(1) - held.x.has(-1), y: held.y.has(1) - held.y.has(-1) })
66
+ const axis = (e) => {
67
+ const k = e.key.toLowerCase()
68
+ if (k === 'a' || k === 'arrowleft') return ['x', -1]
69
+ if (k === 'd' || k === 'arrowright') return ['x', 1]
70
+ if (k === 'w' || k === 'arrowup') return ['y', -1]
71
+ if (k === 's' || k === 'arrowdown') return ['y', 1]
72
+ return null
73
+ }
74
+ const blocked = () => {
75
+ const a = document.activeElement
76
+ return (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT' || a.isContentEditable)) || el.offsetParent === null
 
 
 
 
 
77
  }
78
+ window.addEventListener('keydown', (e) => { if (blocked()) return; const c = axis(e); if (c) { e.preventDefault(); held[c[0]].add(c[1]); apply() } })
79
+ window.addEventListener('keyup', (e) => { const c = axis(e); if (c) { held[c[0]].delete(c[1]); apply() } })
80
+ if (pendingChar) window.tinySetChar(pendingChar)
81
  })
82
 
83
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────