Spaces:
Running
Running
Space: render the FULL Sprite Animations chrome via shared playground
Browse filesReplace 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>
- app.py +7 -16
- build.sh +7 -5
- web/playground.js +692 -0
- web/shell/spriteScene.css +105 -0
- 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 |
-
#
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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"
|
| 14 |
-
npx --yes esbuild "$AB/src/render/spriteSheet.js"
|
| 15 |
-
npx --yes esbuild "$AB/src/render/spriteScene.js"
|
|
|
|
| 16 |
|
| 17 |
-
# 2. App shell (nav IR + sidebar CSS/JS)
|
| 18 |
-
# the React app, which renders the
|
| 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 {
|
| 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
|
| 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 (
|
| 42 |
}
|
|
|
|
| 43 |
|
| 44 |
-
// ── Sprite Animations tab — shared
|
| 45 |
-
// The
|
| 46 |
-
//
|
| 47 |
-
//
|
| 48 |
-
//
|
| 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 |
-
|
| 62 |
-
|
| 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) ──────────
|