polats Claude Opus 4.8 (1M context) commited on
Commit
0c573fe
·
1 Parent(s): 23ab228

Space: render the FULL Sprite Animations chrome via shared playground

Browse files

Replace the dropdown+buttons with the same spritePlayground.js + spriteScene.css the
React app uses: team-grouped character picker, on-canvas instructions, aimed-attack
compass, and the extras list with keypress hints — all from one source. app.py just
hosts the container; the playground builds everything. build.sh now bundles
playground.js and copies the chrome CSS too.

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

Files changed (5) hide show
  1. app.py +7 -16
  2. build.sh +7 -5
  3. web/playground.js +692 -0
  4. web/shell/spriteScene.css +105 -0
  5. web/tiny.js +11 -44
app.py CHANGED
@@ -18,12 +18,9 @@ from fastapi.staticfiles import StaticFiles
18
  HERE = os.path.dirname(os.path.abspath(__file__))
19
  WEB = os.path.join(HERE, "web")
20
 
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,
@@ -45,6 +42,7 @@ HIDE_TABS = ('<style>.tab-container[role="tablist"]{position:absolute!important;
45
  HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
46
  + HIDE_TABS +
47
  '<link rel="stylesheet" href="/web/shell/sidebar.css">'
 
48
  '<script type="module" src="/web/tiny.js"></script>'
49
  '<script src="/web/shell/sidebar.js"></script>')
50
  STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
@@ -104,14 +102,9 @@ with gr.Blocks(title="Tiny Army") as demo:
104
  with gr.Tab("Battle") as battle_tab:
105
  gr.HTML(f'<div id="battle-stage" style="{STAGE}"></div>')
106
  with gr.Tab("Sprite Animations") as sprite_tab:
107
- char = gr.Dropdown(CHARACTERS, value=CHARACTERS[0][1], label="Character")
108
- with gr.Row():
109
- for _label, _state in ACTIONS:
110
- gr.Button(_label, size="sm").click(None, None, None, js=f"()=>window.tinyAction('{_state}')")
111
- gr.HTML(f'<div id="sprite-stage" style="{STAGE.replace("56vh", "48vh")}"></div>')
112
- gr.Markdown("*Tap / click the stage to walk there · **WASD** to move · "
113
- "buttons play one-shot actions.*")
114
- char.change(None, [char], None, js="(c)=>window.tinySetChar(c)")
115
  # Pixi canvases start hidden (0×0); re-measure them when a tab is shown.
116
  battle_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
117
  sprite_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()")
@@ -121,8 +114,6 @@ with gr.Blocks(title="Tiny Army") as demo:
121
  traits = gr.Textbox("Cautious, Veteran, Vengeful", label="Traits")
122
  out = gr.Textbox(label="War diary", lines=6)
123
  gr.Button("Write diary", variant="primary").click(diary, [unit, traits], out)
124
- # Load the initial character once the page (and the canvas) are up.
125
- demo.load(None, [char], None, js="(c)=>{window.tinySetChar&&window.tinySetChar(c)}")
126
 
127
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
128
  fastapi_app = FastAPI()
 
18
  HERE = os.path.dirname(os.path.abspath(__file__))
19
  WEB = os.path.join(HERE, "web")
20
 
21
+ # The Sprite tab's character picker + controls are built entirely by the shared
22
+ # playground (web/playground.js) from /sprites/characters.json — no Python-side
23
+ # dropdown/buttons needed anymore.
 
 
 
24
 
25
  # NOTE: link sidebar.css here (head) rather than via mount's css_paths — Gradio
26
  # auto-scopes css_paths/css= selectors with a `.gradio-container .contain` prefix,
 
42
  HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
43
  + HIDE_TABS +
44
  '<link rel="stylesheet" href="/web/shell/sidebar.css">'
45
+ '<link rel="stylesheet" href="/web/shell/spriteScene.css">'
46
  '<script type="module" src="/web/tiny.js"></script>'
47
  '<script src="/web/shell/sidebar.js"></script>')
48
  STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
 
102
  with gr.Tab("Battle") as battle_tab:
103
  gr.HTML(f'<div id="battle-stage" style="{STAGE}"></div>')
104
  with gr.Tab("Sprite Animations") as sprite_tab:
105
+ # The shared playground (web/playground.js) builds the whole page —
106
+ # team picker, canvas, instructions, compass, extras — into this div.
107
+ gr.HTML(f'<div id="sprite-stage" style="{STAGE.replace("56vh", "62vh")}"></div>')
 
 
 
 
 
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
 
118
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
119
  fastapi_app = FastAPI()
build.sh CHANGED
@@ -10,14 +10,16 @@ set -euo pipefail
10
  AB="${AB:-../auto-battler}"
11
 
12
  # 1. JS engine/render core → bundled (Pixi injected, so nothing external).
13
- npx --yes esbuild "$AB/src/engine/teamBattle.js" --bundle --format=esm --outfile=web/engine.js
14
- npx --yes esbuild "$AB/src/render/spriteSheet.js" --bundle --format=esm --outfile=web/sheet.js
15
- npx --yes esbuild "$AB/src/render/spriteScene.js" --bundle --format=esm --outfile=web/scene.js
 
16
 
17
- # 2. App shell (nav IR + sidebar CSS/JS) copied verbatim, so it can't drift from
18
- # the React app, which renders the SAME src/shell/nav.json.
19
  mkdir -p web/shell
20
  cp "$AB/src/shell/nav.json" "$AB/src/shell/sidebar.css" "$AB/src/shell/sidebar.js" web/shell/
 
21
 
22
  # 3. Assets → curate the referenced sheets (shadows/extras/companions) for the
23
  # curated characters from the full auto-battler set.
 
10
  AB="${AB:-../auto-battler}"
11
 
12
  # 1. JS engine/render core → bundled (Pixi injected, so nothing external).
13
+ npx --yes esbuild "$AB/src/engine/teamBattle.js" --bundle --format=esm --outfile=web/engine.js
14
+ npx --yes esbuild "$AB/src/render/spriteSheet.js" --bundle --format=esm --outfile=web/sheet.js
15
+ npx --yes esbuild "$AB/src/render/spriteScene.js" --bundle --format=esm --outfile=web/scene.js
16
+ npx --yes esbuild "$AB/src/render/spritePlayground.js" --bundle --format=esm --outfile=web/playground.js
17
 
18
+ # 2. App shell (nav IR + sidebar CSS/JS) + the playground chrome CSS copied
19
+ # verbatim, so they can't drift from the React app, which renders the same files.
20
  mkdir -p web/shell
21
  cp "$AB/src/shell/nav.json" "$AB/src/shell/sidebar.css" "$AB/src/shell/sidebar.js" web/shell/
22
+ cp "$AB/src/render/spriteScene.css" web/shell/spriteScene.css
23
 
24
  # 3. Assets → curate the referenced sheets (shadows/extras/companions) for the
25
  # curated characters from the full auto-battler set.
web/playground.js ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ app.canvas.addEventListener("click", onPointerDown);
88
+ host.appendChild(app.canvas);
89
+ app.ticker.add(tick);
90
+ })();
91
+ function onPointerDown(e) {
92
+ if (!app) return;
93
+ const rect = app.canvas.getBoundingClientRect();
94
+ if (!rect.width || !rect.height) return;
95
+ const x = (e.clientX - rect.left) / rect.width * app.screen.width;
96
+ const y = (e.clientY - rect.top) / rect.height * app.screen.height;
97
+ moveTo(x, y);
98
+ }
99
+ function moveTo(x, y) {
100
+ if (!app) return;
101
+ const tx = Math.max(0, Math.min(app.screen.width, x));
102
+ const ty = Math.max(0, Math.min(app.screen.height, y));
103
+ moveTarget = { x: tx, y: ty };
104
+ if (Graphics) {
105
+ if (!marker) {
106
+ marker = new Graphics();
107
+ app.stage.addChildAt(marker, 0);
108
+ }
109
+ marker.clear();
110
+ marker.circle(0, 0, 11).stroke({ color: 7035903, width: 2, alpha: 1 });
111
+ marker.position.set(tx, ty);
112
+ marker.alpha = 0.9;
113
+ markerLife = 30;
114
+ }
115
+ }
116
+ function tick(ticker) {
117
+ const s = sprite;
118
+ const f = frames;
119
+ if (!s || !f) return;
120
+ let vx = keys.x;
121
+ let vy = keys.y;
122
+ let usingTarget = false;
123
+ let seekDX = 0;
124
+ let seekDY = 0;
125
+ if (vx === 0 && vy === 0 && moveTarget) {
126
+ seekDX = moveTarget.x - s.x;
127
+ seekDY = moveTarget.y - s.y;
128
+ const d = Math.hypot(seekDX, seekDY);
129
+ if (d <= ARRIVE) {
130
+ s.x = moveTarget.x;
131
+ s.y = moveTarget.y;
132
+ moveTarget = null;
133
+ } else {
134
+ vx = seekDX;
135
+ vy = seekDY;
136
+ usingTarget = true;
137
+ }
138
+ }
139
+ const wantMove = vx !== 0 || vy !== 0;
140
+ if (pendingAction) {
141
+ const pend = pendingAction;
142
+ pendingAction = null;
143
+ if (f[pend] && (action === null || dieHold)) {
144
+ action = pend;
145
+ dieHold = false;
146
+ applied = "";
147
+ }
148
+ }
149
+ if (dieHold && wantMove) {
150
+ action = null;
151
+ dieHold = false;
152
+ }
153
+ const acting = action !== null;
154
+ if (!acting && wantMove) {
155
+ let sgx, sgy;
156
+ if (usingTarget) {
157
+ sgx = Math.abs(seekDX) > 4 ? Math.sign(seekDX) : 0;
158
+ sgy = Math.abs(seekDY) > 4 ? Math.sign(seekDY) : 0;
159
+ } else {
160
+ sgx = Math.sign(vx);
161
+ sgy = Math.sign(vy);
162
+ }
163
+ if (sgx > 0) side = "right";
164
+ else if (sgx < 0) side = "left";
165
+ if (sgy > 0) depth = "front";
166
+ else if (sgy < 0) depth = "back";
167
+ if (sgx || sgy) dir = { x: sgx, y: sgy };
168
+ const len = Math.hypot(vx, vy) || 1;
169
+ const step = SPEED * ticker.deltaTime;
170
+ s.x += vx / len * step;
171
+ s.y += vy / len * step;
172
+ const halfW = s.width / 2;
173
+ const halfH = s.height / 2;
174
+ s.x = Math.max(halfW, Math.min(app.screen.width - halfW, s.x));
175
+ s.y = Math.max(halfH, Math.min(app.screen.height - halfH, s.y));
176
+ }
177
+ const aimKey = `${dir.x},${dir.y}`;
178
+ if (aimKey !== lastAimKey) {
179
+ lastAimKey = aimKey;
180
+ emit();
181
+ }
182
+ if (shadow) {
183
+ shadow.x = s.x;
184
+ shadow.y = s.y;
185
+ }
186
+ if (overlay?.visible) {
187
+ overlay.x = s.x;
188
+ overlay.y = s.y;
189
+ }
190
+ if (marker && markerLife > 0) {
191
+ markerLife -= ticker.deltaTime;
192
+ const t = Math.max(0, markerLife / 30);
193
+ marker.alpha = t * 0.9;
194
+ marker.scale.set(1 + (1 - t) * 0.6);
195
+ }
196
+ for (let i = flying.length - 1; i >= 0; i--) {
197
+ const fl = flying[i];
198
+ fl.sprite.x += fl.dx * PROJ_SPEED * ticker.deltaTime;
199
+ fl.sprite.y += fl.dy * PROJ_SPEED * ticker.deltaTime;
200
+ if (!fl.impFired && fl.impGrid) {
201
+ const dist = Math.hypot(fl.sprite.x - fl.startX, fl.sprite.y - fl.startY);
202
+ if (dist > 150) {
203
+ fl.impFired = true;
204
+ const imp = new AnimatedSprite(fl.impGrid[0]);
205
+ imp.anchor.set(0.5);
206
+ imp.scale.set(SCALE);
207
+ imp.loop = false;
208
+ imp.animationSpeed = anim.attack;
209
+ imp.x = fl.sprite.x;
210
+ imp.y = fl.sprite.y;
211
+ imp.onComplete = () => {
212
+ if (imp.parent) imp.parent.removeChild(imp);
213
+ imp.destroy();
214
+ };
215
+ app.stage.addChild(imp);
216
+ imp.gotoAndPlay(0);
217
+ }
218
+ }
219
+ const os = fl.sprite.x < -128 || fl.sprite.x > app.screen.width + 128 || fl.sprite.y < -128 || fl.sprite.y > app.screen.height + 128;
220
+ if (os) {
221
+ if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite);
222
+ fl.sprite.destroy();
223
+ flying.splice(i, 1);
224
+ }
225
+ }
226
+ const kind = acting ? action : wantMove ? "walk" : "idle";
227
+ currentKind = kind;
228
+ let fr, key;
229
+ if (kind === "attack" && shoot) {
230
+ const d = dir;
231
+ const useDiag = d.x !== 0 && d.y !== 0 && !!f.attackDiagonal;
232
+ const grid = useDiag ? f.attackDiagonal : f.attack;
233
+ const row = useDiag ? diagRow(d) : orthoRow(d);
234
+ fr = grid[row] ?? grid[0];
235
+ key = `shoot:${useDiag ? "d" : "o"}${row}`;
236
+ } else {
237
+ const facing = `${depth}-${side}`;
238
+ fr = rowFor(f[kind], facing);
239
+ key = `${kind}:${facing}`;
240
+ }
241
+ if (key !== applied) {
242
+ applied = key;
243
+ const oneShot = kind !== "idle" && kind !== "walk";
244
+ s.textures = fr;
245
+ s.loop = !oneShot;
246
+ s.animationSpeed = anim[kind] ?? (oneShot ? anim.attack : anim.walk);
247
+ s.gotoAndPlay(0);
248
+ const actionKey = action ?? kind;
249
+ const facing = `${depth}-${side}`;
250
+ if (shadow) {
251
+ let shadGrid;
252
+ if (kind === "attack" && shoot) {
253
+ const d = dir;
254
+ const useDiag = d.x !== 0 && d.y !== 0 && !!f["shd:attackDiagonal"];
255
+ shadGrid = useDiag ? f["shd:attackDiagonal"] : f["shd:attack"];
256
+ if (shadGrid) {
257
+ const row = useDiag ? diagRow(d) : orthoRow(d);
258
+ shadow.textures = shadGrid[row] ?? shadGrid[0];
259
+ }
260
+ } else {
261
+ shadGrid = f["shd:" + actionKey];
262
+ if (shadGrid) shadow.textures = rowFor(shadGrid, facing);
263
+ }
264
+ if (shadGrid && shadowsOn) {
265
+ shadow.loop = s.loop;
266
+ shadow.animationSpeed = s.animationSpeed;
267
+ shadow.visible = true;
268
+ shadow.gotoAndPlay(0);
269
+ } else {
270
+ shadow.visible = false;
271
+ }
272
+ }
273
+ if (overlay) {
274
+ const effGrid = effectsOn && f["eff:" + actionKey];
275
+ if (effGrid && oneShot) {
276
+ overlay.textures = rowFor(effGrid, facing);
277
+ overlay.loop = false;
278
+ overlay.animationSpeed = s.animationSpeed;
279
+ overlay.x = s.x;
280
+ overlay.y = s.y;
281
+ overlay.visible = true;
282
+ overlay.gotoAndPlay(0);
283
+ } else {
284
+ overlay.visible = false;
285
+ }
286
+ }
287
+ if (oneShot && effectsOn) {
288
+ const projGrid = f["proj:" + actionKey];
289
+ if (projGrid) {
290
+ const d = dir;
291
+ const row = orthoRow(d);
292
+ const proj = new AnimatedSprite(projGrid[row] ?? projGrid[0]);
293
+ proj.anchor.set(0.5);
294
+ proj.scale.set(SCALE);
295
+ proj.loop = true;
296
+ proj.animationSpeed = anim.attack;
297
+ proj.x = s.x;
298
+ proj.y = s.y;
299
+ proj.gotoAndPlay(0);
300
+ app.stage.addChild(proj);
301
+ const len = Math.hypot(d.x, d.y) || 1;
302
+ flying.push({
303
+ sprite: proj,
304
+ dx: d.x / len,
305
+ dy: d.y / len,
306
+ startX: s.x,
307
+ startY: s.y,
308
+ impGrid: f["imp:" + actionKey] ?? null,
309
+ impFired: false
310
+ });
311
+ }
312
+ }
313
+ }
314
+ }
315
+ async function setCharacter(active) {
316
+ await ready;
317
+ if (!app || !active) return;
318
+ const sheets = [
319
+ ...STATE_KEYS.filter((k) => active[k]).map((k) => ({ gridKey: k, url: active[k], type: "body" })),
320
+ ...(active.extras ?? []).map((e) => ({ gridKey: "x:" + e.key, url: e.url, type: "body" })),
321
+ ...active.attackEffect ? [{ gridKey: "eff:attack", url: active.attackEffect, type: "eff" }] : [],
322
+ ...active.attackProjectile ? [{ gridKey: "proj:attack", url: active.attackProjectile, type: "proj" }] : [],
323
+ ...active.attackImpact ? [{ gridKey: "imp:attack", url: active.attackImpact, type: "imp" }] : [],
324
+ ...Object.entries(active.shadows ?? {}).map(([k, url]) => ({ gridKey: "shd:" + k, url, type: "body" })),
325
+ ...(active.extras ?? []).flatMap((e) => [
326
+ e.effect ? { gridKey: "eff:x:" + e.key, url: e.effect, type: "eff" } : null,
327
+ e.projectile ? { gridKey: "proj:x:" + e.key, url: e.projectile, type: "proj" } : null,
328
+ e.impact ? { gridKey: "imp:x:" + e.key, url: e.impact, type: "imp" } : null,
329
+ e.shadow ? { gridKey: "shd:x:" + e.key, url: e.shadow, type: "body" } : null
330
+ ].filter(Boolean))
331
+ ];
332
+ const texs = await Promise.all(sheets.map((s) => Assets.load(urlFor(s.url)).then((t) => t, () => null)));
333
+ if (!app) return;
334
+ const idleIdx = sheets.findIndex((s) => s.gridKey === "idle");
335
+ if (idleIdx < 0 || !texs[idleIdx]) return;
336
+ const grids = {};
337
+ const cell = cellOf(texs[idleIdx].source.height);
338
+ sheets.forEach((s, i) => {
339
+ const t = texs[i];
340
+ if (!t) return;
341
+ t.source.scaleMode = "nearest";
342
+ if (s.type === "proj") grids[s.gridKey] = sliceGrid(t, Math.max(1, Math.round(t.source.height / ROWS)));
343
+ else if (s.type === "imp") grids[s.gridKey] = sliceGrid(t, Math.max(1, t.source.height));
344
+ else grids[s.gridKey] = sliceGrid(t, cell);
345
+ });
346
+ frames = grids;
347
+ extras = active.extras ?? [];
348
+ shoot = usesAimedAttack(active);
349
+ action = null;
350
+ pendingAction = null;
351
+ dieHold = false;
352
+ applied = "";
353
+ lastAimKey = "";
354
+ const startFrames = rowFor(grids.idle, `${depth}-${side}`);
355
+ if (!sprite) {
356
+ const shd = new AnimatedSprite(startFrames);
357
+ shd.anchor.set(0.5);
358
+ shd.scale.set(SCALE);
359
+ shd.loop = true;
360
+ shd.visible = false;
361
+ shd.animationSpeed = anim.idle;
362
+ shd.x = app.screen.width / 2;
363
+ shd.y = app.screen.height / 2;
364
+ app.stage.addChild(shd);
365
+ shadow = shd;
366
+ const s = new AnimatedSprite(startFrames);
367
+ s.anchor.set(0.5);
368
+ s.scale.set(SCALE);
369
+ s.loop = true;
370
+ s.animationSpeed = anim.idle;
371
+ s.x = app.screen.width / 2;
372
+ s.y = app.screen.height / 2;
373
+ s.onComplete = () => {
374
+ if (action === "die") dieHold = true;
375
+ else action = null;
376
+ };
377
+ app.stage.addChild(s);
378
+ s.gotoAndPlay(0);
379
+ sprite = s;
380
+ const ov = new AnimatedSprite(startFrames);
381
+ ov.anchor.set(0.5);
382
+ ov.scale.set(SCALE);
383
+ ov.loop = false;
384
+ ov.visible = false;
385
+ ov.animationSpeed = anim.attack;
386
+ ov.onComplete = () => {
387
+ ov.visible = false;
388
+ };
389
+ app.stage.addChild(ov);
390
+ overlay = ov;
391
+ } else {
392
+ const s = sprite;
393
+ s.loop = true;
394
+ s.textures = startFrames;
395
+ s.animationSpeed = anim.idle;
396
+ s.gotoAndPlay(0);
397
+ if (shadow) shadow.visible = false;
398
+ if (overlay) overlay.visible = false;
399
+ for (const fl of flying) {
400
+ if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite);
401
+ fl.sprite.destroy();
402
+ }
403
+ flying = [];
404
+ }
405
+ emit();
406
+ }
407
+ function setVelocity(v) {
408
+ keys = { x: v?.x || 0, y: v?.y || 0 };
409
+ if (keys.x || keys.y) moveTarget = null;
410
+ }
411
+ function triggerAction(stateKey) {
412
+ if (stateKey) pendingAction = stateKey;
413
+ }
414
+ function setToggles(t) {
415
+ if (t && "effects" in t) effectsOn = !!t.effects;
416
+ if (t && "shadows" in t) {
417
+ shadowsOn = !!t.shadows;
418
+ if (shadow && !shadowsOn) shadow.visible = false;
419
+ }
420
+ emit();
421
+ }
422
+ return {
423
+ ready,
424
+ setCharacter,
425
+ setVelocity,
426
+ triggerAction,
427
+ moveTo,
428
+ setToggles,
429
+ getSnapshot: snapshot,
430
+ onChange: (cb) => {
431
+ changeCb = cb;
432
+ },
433
+ resize: () => {
434
+ if (app) app.resize();
435
+ },
436
+ // Re-place the character at the centre of the current canvas — used by hosts
437
+ // that mount the stage in a hidden/0-size tab and reveal it later (the Space).
438
+ recenter: () => {
439
+ if (!app || !sprite) return;
440
+ sprite.x = app.screen.width / 2;
441
+ sprite.y = app.screen.height / 2;
442
+ if (shadow) {
443
+ shadow.x = sprite.x;
444
+ shadow.y = sprite.y;
445
+ }
446
+ moveTarget = null;
447
+ },
448
+ destroy: () => {
449
+ destroyed = true;
450
+ const a = app;
451
+ app = null;
452
+ if (a) {
453
+ a.canvas.removeEventListener("pointerdown", onPointerDown);
454
+ a.canvas.removeEventListener("click", onPointerDown);
455
+ a.destroy(true, { children: true });
456
+ }
457
+ sprite = shadow = overlay = frames = marker = null;
458
+ flying = [];
459
+ }
460
+ };
461
+ }
462
+
463
+ // ../auto-battler/src/render/spritePlayground.js
464
+ var COMPASS_CELLS = [
465
+ { dir: "-1,-1", glyph: "\u2196" },
466
+ { dir: "0,-1", glyph: "\u2191" },
467
+ { dir: "1,-1", glyph: "\u2197" },
468
+ { dir: "-1,0", glyph: "\u2190" },
469
+ { dir: "0,0", glyph: "\xB7" },
470
+ { dir: "1,0", glyph: "\u2192" },
471
+ { dir: "-1,1", glyph: "\u2199" },
472
+ { dir: "0,1", glyph: "\u2193" },
473
+ { dir: "1,1", glyph: "\u2198" }
474
+ ];
475
+ var ACTION_BY_CODE = Object.fromEntries(SCENE_ACTIONS.map((a) => [a.code, a.state]));
476
+ function el(tag, props = {}, kids = []) {
477
+ const n = document.createElement(tag);
478
+ for (const [k, v] of Object.entries(props)) {
479
+ if (k === "class") n.className = v;
480
+ else if (k === "html") n.innerHTML = v;
481
+ else if (k === "dataset") Object.assign(n.dataset, v);
482
+ else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v);
483
+ else if (v != null) n.setAttribute(k, v);
484
+ }
485
+ for (const kid of [].concat(kids)) if (kid != null) n.append(kid);
486
+ return n;
487
+ }
488
+ var flatList = (packs) => (packs ?? []).flatMap((p) => p.characters ?? []);
489
+ function mountSpritePlayground(pixi, host, opts = {}) {
490
+ const packs = opts.packs ?? [];
491
+ const all = flatList(packs);
492
+ let active = all.find((c) => c.slug === opts.initialSlug) ?? all[0] ?? null;
493
+ let selIdx = 0;
494
+ const canvasEl = el("div", { class: "movement-canvas" });
495
+ const stage = el("div", { class: "movement-stage" }, [canvasEl]);
496
+ const charLinks = /* @__PURE__ */ new Map();
497
+ const picker = el("aside", { class: "movement-picker" }, [
498
+ el("h2", { class: "movement-picker-title" }, "Characters"),
499
+ ...packs.map((p) => {
500
+ const here = (p.characters ?? []).some((c) => c.slug === active?.slug);
501
+ const lis = (p.characters ?? []).map((c) => {
502
+ const a = el("a", {
503
+ class: "movement-char",
504
+ href: "#",
505
+ dataset: { slug: c.slug },
506
+ onclick: (e) => {
507
+ e.preventDefault();
508
+ setCharacter(c.slug);
509
+ }
510
+ }, c.name);
511
+ charLinks.set(c.slug, a);
512
+ return el("li", {}, a);
513
+ });
514
+ return el("details", { class: "movement-pack", ...here ? { open: "" } : {} }, [
515
+ el("summary", {}, p.name),
516
+ el("ul", {}, lis)
517
+ ]);
518
+ })
519
+ ]);
520
+ const view = el("div", { class: "movement-view" }, [picker, stage]);
521
+ host.appendChild(view);
522
+ const scene = createSpriteScene(pixi, canvasEl, { urlFor: opts.urlFor });
523
+ let compassEl = null;
524
+ let compassLabel = null;
525
+ scene.onChange((snap) => {
526
+ if (!compassEl) return;
527
+ for (const cell of compassEl.children) {
528
+ if (cell.dataset.dir != null) cell.classList.toggle("on", cell.dataset.dir === snap.aim);
529
+ }
530
+ const [x, y] = snap.aim.split(",").map(Number);
531
+ const diag = x !== 0 && y !== 0;
532
+ if (compassLabel) compassLabel.textContent = `${snap.aim} ${diag ? "diag" : "ortho"} r${diag ? diagRow({ x, y }) : orthoRow({ x, y })}`;
533
+ });
534
+ function renderChrome() {
535
+ stage.querySelectorAll(".movement-hint,.movement-compass-wrap,.movement-extras").forEach((n) => n.remove());
536
+ compassEl = compassLabel = null;
537
+ if (!active) return;
538
+ if (usesAimedAttack(active)) {
539
+ compassEl = el(
540
+ "div",
541
+ { class: "movement-compass" },
542
+ COMPASS_CELLS.map((c) => el("span", { dataset: { dir: c.dir } }, c.glyph))
543
+ );
544
+ compassLabel = el("div", { class: "movement-compass-label" }, "1,1 diag r0");
545
+ stage.append(el("div", { class: "movement-compass-wrap", "aria-hidden": "true" }, [compassEl, compassLabel]));
546
+ }
547
+ const hint = el("div", { class: "movement-hint", "aria-hidden": "true" }, [
548
+ el("span", { class: "movement-key" }, "WASD"),
549
+ " move \xB7 ",
550
+ el("span", { class: "movement-key" }, "tap"),
551
+ " walk there"
552
+ ]);
553
+ if (active.shadows) hint.append(" \xB7 ", toggle("shadows", true, (on) => scene.setToggles({ shadows: on })));
554
+ if ((active.extras?.length ?? 0) > 0) hint.append(" \xB7 ", toggle("effects", true, (on) => scene.setToggles({ effects: on })));
555
+ for (const a of SCENE_ACTIONS) {
556
+ if (!active[a.state]) continue;
557
+ const verb = a.state === "attack" && active.attackVerb ? active.attackVerb : a.verb;
558
+ hint.append(" \xB7 ", el("span", { class: "movement-key" }, a.label), " " + verb);
559
+ if (a.state === "attack") {
560
+ for (const [f, t] of [["attackEffect", "e"], ["attackProjectile", "p"], ["attackImpact", "i"]])
561
+ if (active[f]) hint.append(el("span", { class: "movement-extra-tag" }, t));
562
+ }
563
+ }
564
+ stage.append(hint);
565
+ if ((active.extras?.length ?? 0) > 0) {
566
+ const list = el("ul", { class: "movement-extras-list" }, active.extras.map((ex, i) => {
567
+ const btn = el("button", {
568
+ type: "button",
569
+ class: "movement-extra" + (i === selIdx ? " active" : ""),
570
+ dataset: { i },
571
+ onclick: () => {
572
+ selectExtra(i);
573
+ scene.triggerAction("x:" + ex.key);
574
+ }
575
+ }, [
576
+ i < 9 ? el("span", { class: "movement-extra-num" }, String(i + 1)) : null,
577
+ ex.name,
578
+ ...["effect", "projectile", "impact"].filter((k) => ex[k]).map((k) => el("span", { class: "movement-extra-tag" }, k[0]))
579
+ ]);
580
+ return el("li", {}, btn);
581
+ }));
582
+ stage.append(el("div", { class: "movement-extras" }, [
583
+ el("div", { class: "movement-extras-hint", html: 'extra animations \xB7 <span class="movement-key">[</span> <span class="movement-key">]</span> cycle \xB7 <span class="movement-key">Enter</span> play \xB7 <span class="movement-key">1\u20139</span> quick' }),
584
+ list
585
+ ]));
586
+ }
587
+ }
588
+ function toggle(label, checked, onChange) {
589
+ const cb = el("input", { type: "checkbox", ...checked ? { checked: "" } : {} });
590
+ cb.addEventListener("change", () => onChange(cb.checked));
591
+ return el("label", { class: "movement-effects-toggle" }, [cb, " " + label]);
592
+ }
593
+ function selectExtra(i) {
594
+ selIdx = i;
595
+ stage.querySelectorAll(".movement-extra").forEach((b) => b.classList.toggle("active", +b.dataset.i === i));
596
+ }
597
+ function setCharacter(slug) {
598
+ const c = all.find((x) => x.slug === slug);
599
+ if (!c) return;
600
+ active = c;
601
+ selIdx = 0;
602
+ charLinks.forEach((a2, s) => a2.classList.toggle("active", s === slug));
603
+ const a = charLinks.get(slug);
604
+ const det = a && a.closest("details");
605
+ if (det) det.open = true;
606
+ scene.setCharacter(c);
607
+ renderChrome();
608
+ }
609
+ const held = { x: /* @__PURE__ */ new Set(), y: /* @__PURE__ */ new Set() };
610
+ const applyVel = () => scene.setVelocity({ x: held.x.has(1) - held.x.has(-1), y: held.y.has(1) - held.y.has(-1) });
611
+ const axis = (e) => {
612
+ const k = e.key.toLowerCase();
613
+ if (k === "a" || k === "arrowleft") return ["x", -1];
614
+ if (k === "d" || k === "arrowright") return ["x", 1];
615
+ if (k === "w" || k === "arrowup") return ["y", -1];
616
+ if (k === "s" || k === "arrowdown") return ["y", 1];
617
+ return null;
618
+ };
619
+ const blocked = () => {
620
+ const a = document.activeElement;
621
+ return a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable) || view.offsetParent === null;
622
+ };
623
+ const onKeyDown = (e) => {
624
+ if (blocked()) return;
625
+ const c = axis(e);
626
+ if (c) {
627
+ e.preventDefault();
628
+ held[c[0]].add(c[1]);
629
+ applyVel();
630
+ return;
631
+ }
632
+ const action = ACTION_BY_CODE[e.code];
633
+ if (action) {
634
+ e.preventDefault();
635
+ if (!e.repeat) scene.triggerAction(action);
636
+ return;
637
+ }
638
+ const ex = active?.extras ?? [];
639
+ if (!ex.length) return;
640
+ if (e.code === "BracketLeft") {
641
+ e.preventDefault();
642
+ selectExtra((selIdx - 1 + ex.length) % ex.length);
643
+ return;
644
+ }
645
+ if (e.code === "BracketRight") {
646
+ e.preventDefault();
647
+ selectExtra((selIdx + 1) % ex.length);
648
+ return;
649
+ }
650
+ if (e.code === "Enter") {
651
+ e.preventDefault();
652
+ if (!e.repeat) scene.triggerAction("x:" + ex[selIdx].key);
653
+ return;
654
+ }
655
+ const digit = e.code.match(/^Digit([1-9])$/);
656
+ if (digit) {
657
+ const i = +digit[1] - 1;
658
+ if (i < ex.length) {
659
+ e.preventDefault();
660
+ selectExtra(i);
661
+ scene.triggerAction("x:" + ex[i].key);
662
+ }
663
+ }
664
+ };
665
+ const onKeyUp = (e) => {
666
+ if (blocked()) return;
667
+ const c = axis(e);
668
+ if (c) {
669
+ held[c[0]].delete(c[1]);
670
+ applyVel();
671
+ }
672
+ };
673
+ window.addEventListener("keydown", onKeyDown);
674
+ window.addEventListener("keyup", onKeyUp);
675
+ renderChrome();
676
+ if (active) setCharacter(active.slug);
677
+ return {
678
+ setCharacter,
679
+ getSnapshot: () => scene.getSnapshot(),
680
+ resize: () => scene.resize(),
681
+ recenter: () => scene.recenter?.(),
682
+ destroy: () => {
683
+ window.removeEventListener("keydown", onKeyDown);
684
+ window.removeEventListener("keyup", onKeyUp);
685
+ scene.destroy();
686
+ if (view.parentNode) view.parentNode.removeChild(view);
687
+ }
688
+ };
689
+ }
690
+ export {
691
+ mountSpritePlayground
692
+ };
web/shell/spriteScene.css ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared chrome for the Sprite Animations playground — the team-grouped character
2
+ * picker, the on-canvas hint, the aimed-attack compass, and the extras list.
3
+ * ONE source for the React app (src/views/Movement.jsx) and the Gradio Space
4
+ * (tiny-army web/playground.js). It is fully self-contained: the palette is
5
+ * scoped on `.movement-view` (copied from the auto-battler theme) so it looks
6
+ * identical whether mounted in the app or inside Gradio, with no dependency on
7
+ * the host's theme vars or sidebar classes.
8
+ */
9
+ .movement-view {
10
+ --mv-ink: #141821;
11
+ --mv-ink-muted: #6d6a5f;
12
+ --mv-ink-faint: #8a8574;
13
+ --mv-paper: #f3ebdc;
14
+ --mv-paper-2: #ece2cc;
15
+ --mv-card: #fbf6ea;
16
+ --mv-transmit: #d8271a;
17
+ --mv-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
18
+ display: flex; height: 100%; width: 100%; box-sizing: border-box;
19
+ color: var(--mv-ink);
20
+ }
21
+ .movement-view * { box-sizing: border-box; }
22
+
23
+ /* ── Team-grouped character picker ─────────────────────────────────────────── */
24
+ .movement-picker {
25
+ width: 240px; flex-shrink: 0; border-right: 2px solid var(--mv-ink);
26
+ background: var(--mv-paper-2); overflow-y: auto; padding: 12px;
27
+ }
28
+ .movement-picker-title {
29
+ margin: 0 0 8px; font-family: var(--mv-mono); font-size: 11px; letter-spacing: .2em;
30
+ text-transform: uppercase; color: var(--mv-transmit);
31
+ }
32
+ .movement-pack { margin-top: 8px; border-top: 1px solid var(--mv-paper); }
33
+ .movement-pack > summary {
34
+ cursor: pointer; list-style: none; padding: 6px 4px; font-family: var(--mv-mono);
35
+ font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--mv-ink-muted);
36
+ }
37
+ .movement-pack > summary::-webkit-details-marker { display: none; }
38
+ .movement-pack > summary::before { content: '▸ '; color: var(--mv-ink-faint); }
39
+ .movement-pack[open] > summary::before { content: '▾ '; }
40
+ .movement-pack ul { list-style: none; margin: 0 0 6px; padding: 0; }
41
+ .movement-char {
42
+ display: block; padding: 5px 10px; margin: 1px 0; border-radius: 4px;
43
+ color: var(--mv-ink); text-decoration: none; font-size: 13px; cursor: pointer;
44
+ }
45
+ .movement-char:hover { background: var(--mv-paper); }
46
+ .movement-char.active { background: var(--mv-ink); color: var(--mv-card); }
47
+
48
+ /* ── Stage + canvas ────────────────────────────────────────────────────────── */
49
+ .movement-stage { flex: 1; min-width: 0; position: relative; }
50
+ .movement-canvas { position: absolute; inset: 0; display: flex; }
51
+ .movement-canvas canvas { display: block; width: 100%; height: 100%; }
52
+
53
+ /* ── On-canvas hint (instructions + toggles) ───────────────────────────────── */
54
+ .movement-hint {
55
+ position: absolute; bottom: 12px; left: 12px; z-index: 5; pointer-events: none;
56
+ font-family: var(--mv-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
57
+ color: var(--mv-ink); background: var(--mv-card); border: 1.5px solid var(--mv-ink);
58
+ padding: 4px 8px; box-shadow: 2px 2px 0 var(--mv-ink); max-width: calc(100% - 24px);
59
+ }
60
+ .movement-key {
61
+ font-family: var(--mv-mono); font-weight: 700; background: var(--mv-ink); color: var(--mv-card);
62
+ padding: 0 4px; border-radius: 2px;
63
+ }
64
+ .movement-effects-toggle { margin-left: 4px; cursor: pointer; opacity: .8; pointer-events: auto; }
65
+
66
+ /* ── Aimed-attack debug compass ────────────────────────────────────────────── */
67
+ .movement-compass-wrap {
68
+ position: absolute; top: 12px; left: 12px; z-index: 5; pointer-events: none;
69
+ display: flex; flex-direction: column; gap: 4px;
70
+ background: var(--mv-card); border: 1.5px solid var(--mv-ink); box-shadow: 2px 2px 0 var(--mv-ink); padding: 6px;
71
+ }
72
+ .movement-compass { display: grid; grid-template-columns: repeat(3, 18px); grid-template-rows: repeat(3, 18px); }
73
+ .movement-compass span { display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--mv-ink-faint); }
74
+ .movement-compass span.on { color: var(--mv-card); background: var(--mv-ink); border-radius: 3px; }
75
+ .movement-compass-label { font-family: var(--mv-mono); font-size: 9px; letter-spacing: .08em; color: var(--mv-ink-muted); text-align: center; }
76
+
77
+ /* ── Extras list ───────────────────────────────────────────────────────────── */
78
+ .movement-extras {
79
+ position: absolute; top: 12px; right: 12px; z-index: 5;
80
+ display: flex; flex-direction: column; gap: 6px; align-items: flex-end;
81
+ max-width: min(360px, 55%);
82
+ background: var(--mv-card); border: 1.5px solid var(--mv-ink); box-shadow: 2px 2px 0 var(--mv-ink); padding: 6px 8px;
83
+ }
84
+ .movement-extras-hint {
85
+ font-family: var(--mv-mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--mv-ink-muted);
86
+ }
87
+ .movement-extras-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
88
+ .movement-extra {
89
+ font-family: var(--mv-mono); font-size: 9px; letter-spacing: .04em; text-transform: uppercase;
90
+ color: var(--mv-ink); background: var(--mv-card); border: 1.5px solid var(--mv-ink); cursor: pointer;
91
+ padding: 2px 6px; display: inline-flex; align-items: center; gap: 4px;
92
+ }
93
+ .movement-extra:hover { background: var(--mv-paper); }
94
+ .movement-extra.active { color: var(--mv-card); background: var(--mv-ink); }
95
+ .movement-extra-num { font-weight: 700; opacity: .55; }
96
+ .movement-extra-tag { font-size: 7px; letter-spacing: .06em; padding: 0 2px; margin-left: 3px; border: 1px solid currentColor; opacity: .6; vertical-align: middle; }
97
+
98
+ /* ── Mobile: stack the picker above the stage ──────────────────────────────── */
99
+ @media (max-width: 768px) {
100
+ .movement-view { flex-direction: column; }
101
+ .movement-picker {
102
+ width: 100%; max-height: 32%; border-right: 0; border-bottom: 2px solid var(--mv-ink);
103
+ }
104
+ .movement-extras { max-width: 60%; }
105
+ }
web/tiny.js CHANGED
@@ -6,7 +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
- import { createSpriteScene } from '/web/scene.js'
10
 
11
  function whenEl(id, cb) {
12
  const found = document.getElementById(id)
@@ -33,56 +33,23 @@ const loadSheet = async (url) => {
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
- window.tinySnap = () => (scene ? scene.getSnapshot() : null) // read-only debug accessor
59
-
60
  whenEl('sprite-stage', async (el) => {
61
- scene = createSpriteScene(PIXI, el, { urlFor: spriteUrl })
62
- await scene.ready
63
- // Desktop keyboard (WASD/arrows). Ignored while a form field is focused or the
64
- // stage is hidden, so it never fights Gradio's own inputs on other tabs.
65
- const held = { x: new Set(), y: new Set() }
66
- const apply = () => scene.setVelocity({ x: held.x.has(1) - held.x.has(-1), y: held.y.has(1) - held.y.has(-1) })
67
- const axis = (e) => {
68
- const k = e.key.toLowerCase()
69
- if (k === 'a' || k === 'arrowleft') return ['x', -1]
70
- if (k === 'd' || k === 'arrowright') return ['x', 1]
71
- if (k === 'w' || k === 'arrowup') return ['y', -1]
72
- if (k === 's' || k === 'arrowdown') return ['y', 1]
73
- return null
74
- }
75
- const blocked = () => {
76
- const a = document.activeElement
77
- return (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT' || a.isContentEditable)) || el.offsetParent === null
78
- }
79
- window.addEventListener('keydown', (e) => { if (blocked()) return; const c = axis(e); if (c) { e.preventDefault(); held[c[0]].add(c[1]); apply() } })
80
- window.addEventListener('keyup', (e) => { const c = axis(e); if (c) { held[c[0]].delete(c[1]); apply() } })
81
- // Always load a character on mount. demo.load's js can run before this module
82
- // finishes (tinySetChar still undefined → pendingChar unset), so fall back to
83
- // the first manifest character rather than leaving the stage empty.
84
- const map = await loadChars()
85
- window.tinySetChar(pendingChar || Object.keys(map)[0])
86
  })
87
 
88
  // ── 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 { mountSpritePlayground } from '/web/playground.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 playground = null, battleApp = 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 (playground) { playground.resize(); playground.recenter() } } catch {}
42
  }
43
+ window.tinySnap = () => (playground ? playground.getSnapshot() : null) // read-only debug accessor
44
 
45
+ // ── Sprite Animations tab — shared playground ───────────────────────────────
46
+ // The SAME module the React app uses (auto-battler src/render/spritePlayground.js
47
+ // → /web/playground.js, Pixi injected) builds the entire page: team-grouped
48
+ // character picker, stage, canvas, instructions, aimed-attack compass and extras
49
+ // list and the shared render core. styled by /web/shell/spriteScene.css.
 
 
 
 
 
 
 
 
 
 
 
50
  whenEl('sprite-stage', async (el) => {
51
+ const man = await fetch('/sprites/characters.json').then((r) => r.json())
52
+ playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  })
54
 
55
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────