Spaces:
Running
Running
Space: Sprite tab uses the shared spriteScene render core + click/tap-to-move
Browse filesThe 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>
- app.py +11 -7
- build.sh +16 -0
- web/engine.js +4 -4
- web/scene.js +463 -0
- web/sheet.js +1 -1
- 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 100 |
-
|
| 101 |
gr.HTML(f'<div id="sprite-stage" style="{STAGE.replace("56vh", "48vh")}"></div>')
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 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 |
-
#
|
| 114 |
-
demo.load(None, [char
|
| 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
|
| 36 |
window.tinyResize = () => {
|
| 37 |
try { battleApp && battleApp.resize() } catch {}
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
// ── Sprite Animations tab ───────────────────────────────
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
whenEl('sprite-stage', async (el) => {
|
| 47 |
-
|
| 48 |
-
await
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
const
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 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 (
|
|
|
|
|
|
|
| 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) ──────────
|