Spaces:
Running
Game page: directly-controlled Forgotten Plains battle; rename to "Game"
Browse filesReplace the Battle tab's combat window with the new single-sim map game
(web/comboBattler.js, bundled from auto-battler): control the persona with
WASD/tap, attack + cast skills (Space / 1–3 / on-screen ⚔ buttons), enemies
aggro on proximity, camera drag/scroll/pinch like the World Map sandbox.
- tiny.js: mount mountComboBattler on #battle-stage (persona → engine profession
+ class skills; enemy band with aggroRadius); drop the old hardcoded 4v4 loop.
- build.sh: esbuild web/comboBattler.js; sed the sidebar label "Combo-Battler" →
"Game" (Space-only; auto-battler's own nav is unchanged).
- personaPanel.js: export CLASS_SLUG (single-source the class→slug map).
- Rebuilt bundles pick up the additive teamBattle terrain/aggro hooks.
- app.py + requirements.txt: load a local .env for `python app.py` (python-dotenv;
no-op on the Space where real secrets are used).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +12 -0
- build.sh +8 -0
- requirements.txt +3 -0
- web/classesSandbox.js +32 -13
- web/comboBattler.js +0 -0
- web/enemiesSandbox.js +32 -13
- web/engine.js +32 -13
- web/mapSandbox.js +26 -4
- web/personaPanel.js +1 -1
- web/shell/nav.json +1 -1
- web/tiny.js +36 -87
|
@@ -21,6 +21,18 @@ import os
|
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# ZeroGPU requires the spaces shim to be imported before torch. Locally, or on
|
| 25 |
# non-ZeroGPU hardware, this falls back to a no-op decorator.
|
| 26 |
try:
|
|
|
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
|
| 24 |
+
# Local dev convenience: load a sibling .env (HF_TOKEN, TINY_*_SPACE keys, etc.) so
|
| 25 |
+
# `python app.py` picks them up the same way the HF Space gets them from secrets.
|
| 26 |
+
# override=False → if a var is already set in the real environment (as on the
|
| 27 |
+
# Space, where there is no .env), that value wins. Optional dep, so a missing
|
| 28 |
+
# install just skips it.
|
| 29 |
+
try:
|
| 30 |
+
from dotenv import load_dotenv
|
| 31 |
+
|
| 32 |
+
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
| 33 |
+
except ImportError:
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
# ZeroGPU requires the spaces shim to be imported before torch. Locally, or on
|
| 37 |
# non-ZeroGPU hardware, this falls back to a no-op decorator.
|
| 38 |
try:
|
|
@@ -22,11 +22,19 @@ npx --yes esbuild "$AB/src/render/enemiesSandbox.js" --bundle --format=esm --o
|
|
| 22 |
# Orc Kingdom / Forgotten Plains / Interiors / Towers, each with Generated/Tilesheet/Reference).
|
| 23 |
# Pulls in every map renderer + the shared chunked-map engine. Pixi injected by web/tiny.js.
|
| 24 |
npx --yes esbuild "$AB/src/render/mapSandbox.js" --bundle --format=esm --outfile=web/mapSandbox.js
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# 2. App shell (nav IR + sidebar CSS/JS) + the playground chrome CSS → copied
|
| 27 |
# verbatim, so they can't drift from the React app, which renders the same files.
|
| 28 |
mkdir -p web/shell
|
| 29 |
cp "$AB/src/shell/nav.json" "$AB/src/shell/sidebar.css" "$AB/src/shell/sidebar.js" web/shell/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
# Design tokens — the shared component CSS (classes/worldmap/spriteScene) references the global
|
| 31 |
# :root palette/fonts/shadows defined in the React app's styles.css. Extract just that :root block
|
| 32 |
# (NOT the app-specific selectors) so the Space's components render with auto-battler's look.
|
|
|
|
| 22 |
# Orc Kingdom / Forgotten Plains / Interiors / Towers, each with Generated/Tilesheet/Reference).
|
| 23 |
# Pulls in every map renderer + the shared chunked-map engine. Pixi injected by web/tiny.js.
|
| 24 |
npx --yes esbuild "$AB/src/render/mapSandbox.js" --bundle --format=esm --outfile=web/mapSandbox.js
|
| 25 |
+
# Game page (the Space's "Battle" tab) — Forgotten Plains roam + on-map real-time combat. Drops a
|
| 26 |
+
# persona on the map, walks it via A*, (later) fights nearby enemies. Pulls in the chunked-map +
|
| 27 |
+
# combat engine + sim. This is a Space-only surface; auto-battler's own Combo-Battler game is untouched.
|
| 28 |
+
npx --yes esbuild "$AB/src/render/comboBattler.js" --bundle --format=esm --outfile=web/comboBattler.js
|
| 29 |
|
| 30 |
# 2. App shell (nav IR + sidebar CSS/JS) + the playground chrome CSS → copied
|
| 31 |
# verbatim, so they can't drift from the React app, which renders the same files.
|
| 32 |
mkdir -p web/shell
|
| 33 |
cp "$AB/src/shell/nav.json" "$AB/src/shell/sidebar.css" "$AB/src/shell/sidebar.js" web/shell/
|
| 34 |
+
# Tiny-army-only label override: the "Battle" page here is the new Game surface (Forgotten Plains
|
| 35 |
+
# roam + on-map combat), not auto-battler's React Combo-Battler. Rename just the sidebar label;
|
| 36 |
+
# the Gradio tab ("Battle"/#battle-stage) and auto-battler's own nav.json are left untouched.
|
| 37 |
+
sed -i 's/"label": "Combo-Battler"/"label": "Game"/' web/shell/nav.json
|
| 38 |
# Design tokens — the shared component CSS (classes/worldmap/spriteScene) references the global
|
| 39 |
# :root palette/fonts/shadows defined in the React app's styles.css. Extract just that :root block
|
| 40 |
# (NOT the app-specific selectors) so the Space's components render with auto-battler's look.
|
|
@@ -1,6 +1,9 @@
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
gradio==6.15.2
|
| 3 |
huggingface_hub
|
|
|
|
|
|
|
|
|
|
| 4 |
# llama.cpp runtime for the persona + war-diary model. The CPU wheel index ships a
|
| 5 |
# prebuilt py3-none-manylinux_2_17_x86_64 wheel (no source compile needed). Pulled
|
| 6 |
# via the --extra-index-url in the Dockerfile.
|
|
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
gradio==6.15.2
|
| 3 |
huggingface_hub
|
| 4 |
+
# Loads .env for local `python app.py` runs (see load_dotenv in app.py). On the
|
| 5 |
+
# Space there's no .env, so it's a no-op and the real Space secrets are used.
|
| 6 |
+
python-dotenv
|
| 7 |
# llama.cpp runtime for the persona + war-diary model. The CPU wheel index ships a
|
| 8 |
# prebuilt py3-none-manylinux_2_17_x86_64 wheel (no source compile needed). Pulled
|
| 9 |
# via the --extra-index-url in the Dockerfile.
|
|
@@ -1017,14 +1017,16 @@ function makeActor(unit, team, id, slot) {
|
|
| 1017 |
prep: null,
|
| 1018 |
alive: true,
|
| 1019 |
mods: [],
|
| 1020 |
-
kd: 0
|
|
|
|
|
|
|
| 1021 |
};
|
| 1022 |
}
|
| 1023 |
-
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) {
|
| 1024 |
const actors = [];
|
| 1025 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 1026 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 1027 |
-
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} };
|
| 1028 |
}
|
| 1029 |
function setInput(b, id, cmd) {
|
| 1030 |
if (!b.input) b.input = {};
|
|
@@ -1563,11 +1565,11 @@ function moveActor(b, a, enemy, dt) {
|
|
| 1563 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1564 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1565 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
a.vx = vx;
|
| 1569 |
a.vy = vy;
|
| 1570 |
-
a.moving = true;
|
| 1571 |
}
|
| 1572 |
var RVO_TAU = 1.6;
|
| 1573 |
var RVO_RANGE = 280;
|
|
@@ -1619,6 +1621,17 @@ function timeToHit(px, py, rvx, rvy, R) {
|
|
| 1619 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1620 |
}
|
| 1621 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1622 |
function resolveOverlaps(b) {
|
| 1623 |
const live = b.actors.filter((a) => a.alive);
|
| 1624 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
@@ -1650,9 +1663,9 @@ function stepPlayer(b, a, foe, dt) {
|
|
| 1650 |
if (mx || my) {
|
| 1651 |
const len = Math.hypot(mx, my) || 1;
|
| 1652 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1653 |
-
|
| 1654 |
-
|
| 1655 |
-
a.moving = true;
|
| 1656 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1657 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1658 |
a.facing = a.faceX;
|
|
@@ -1738,10 +1751,16 @@ function step(b, dt) {
|
|
| 1738 |
for (const a of b.actors) {
|
| 1739 |
if (!a.alive || b.over) continue;
|
| 1740 |
const enemy = nearestFoe(b, a);
|
| 1741 |
-
if (!enemy) continue;
|
| 1742 |
-
a.
|
| 1743 |
-
|
| 1744 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1745 |
a.moving = false;
|
| 1746 |
if (isKd(b, a)) {
|
| 1747 |
a.casting = null;
|
|
|
|
| 1017 |
prep: null,
|
| 1018 |
alive: true,
|
| 1019 |
mods: [],
|
| 1020 |
+
kd: 0,
|
| 1021 |
+
aggroRadius: unit.aggroRadius ?? null
|
| 1022 |
+
// optional: idle until a foe is within this distance (else always engage)
|
| 1023 |
};
|
| 1024 |
}
|
| 1025 |
+
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null } = {}) {
|
| 1026 |
const actors = [];
|
| 1027 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 1028 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 1029 |
+
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {}, world };
|
| 1030 |
}
|
| 1031 |
function setInput(b, id, cmd) {
|
| 1032 |
if (!b.input) b.input = {};
|
|
|
|
| 1565 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1566 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1567 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1568 |
+
const px = a.x, py = a.y;
|
| 1569 |
+
stepMove(b, a, vx, vy, dt);
|
| 1570 |
a.vx = vx;
|
| 1571 |
a.vy = vy;
|
| 1572 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1573 |
}
|
| 1574 |
var RVO_TAU = 1.6;
|
| 1575 |
var RVO_RANGE = 280;
|
|
|
|
| 1621 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1622 |
}
|
| 1623 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
| 1624 |
+
function stepMove(b, a, vx, vy, dt) {
|
| 1625 |
+
const w = b.world && b.world.walkable;
|
| 1626 |
+
let nx = clampField(a.x + vx * dt, a.radius, FIELD.w);
|
| 1627 |
+
let ny = clampField(a.y + vy * dt, a.radius, FIELD.h);
|
| 1628 |
+
if (w) {
|
| 1629 |
+
if (!w(nx, a.y)) nx = a.x;
|
| 1630 |
+
if (!w(nx, ny)) ny = a.y;
|
| 1631 |
+
}
|
| 1632 |
+
a.x = nx;
|
| 1633 |
+
a.y = ny;
|
| 1634 |
+
}
|
| 1635 |
function resolveOverlaps(b) {
|
| 1636 |
const live = b.actors.filter((a) => a.alive);
|
| 1637 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
|
|
| 1663 |
if (mx || my) {
|
| 1664 |
const len = Math.hypot(mx, my) || 1;
|
| 1665 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1666 |
+
const px = a.x, py = a.y;
|
| 1667 |
+
stepMove(b, a, mx / len * speed, my / len * speed, dt);
|
| 1668 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1669 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1670 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1671 |
a.facing = a.faceX;
|
|
|
|
| 1751 |
for (const a of b.actors) {
|
| 1752 |
if (!a.alive || b.over) continue;
|
| 1753 |
const enemy = nearestFoe(b, a);
|
| 1754 |
+
if (!enemy && a.control !== "player") continue;
|
| 1755 |
+
if (a.aggroRadius != null && a.control !== "player" && dist(a, enemy) > a.aggroRadius) {
|
| 1756 |
+
a.moving = false;
|
| 1757 |
+
continue;
|
| 1758 |
+
}
|
| 1759 |
+
if (enemy) {
|
| 1760 |
+
a.facing = enemy.x < a.x ? -1 : 1;
|
| 1761 |
+
a.faceX = a.facing;
|
| 1762 |
+
a.faceY = enemy.y < a.y ? -1 : 1;
|
| 1763 |
+
}
|
| 1764 |
a.moving = false;
|
| 1765 |
if (isKd(b, a)) {
|
| 1766 |
a.casting = null;
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -850,14 +850,16 @@ function makeActor(unit, team, id, slot) {
|
|
| 850 |
prep: null,
|
| 851 |
alive: true,
|
| 852 |
mods: [],
|
| 853 |
-
kd: 0
|
|
|
|
|
|
|
| 854 |
};
|
| 855 |
}
|
| 856 |
-
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) {
|
| 857 |
const actors = [];
|
| 858 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 859 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 860 |
-
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} };
|
| 861 |
}
|
| 862 |
function setInput(b, id, cmd) {
|
| 863 |
if (!b.input) b.input = {};
|
|
@@ -1396,11 +1398,11 @@ function moveActor(b, a, enemy, dt) {
|
|
| 1396 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1397 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1398 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
a.vx = vx;
|
| 1402 |
a.vy = vy;
|
| 1403 |
-
a.moving = true;
|
| 1404 |
}
|
| 1405 |
var RVO_TAU = 1.6;
|
| 1406 |
var RVO_RANGE = 280;
|
|
@@ -1452,6 +1454,17 @@ function timeToHit(px, py, rvx, rvy, R) {
|
|
| 1452 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1453 |
}
|
| 1454 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1455 |
function resolveOverlaps(b) {
|
| 1456 |
const live = b.actors.filter((a) => a.alive);
|
| 1457 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
@@ -1483,9 +1496,9 @@ function stepPlayer(b, a, foe, dt) {
|
|
| 1483 |
if (mx || my) {
|
| 1484 |
const len = Math.hypot(mx, my) || 1;
|
| 1485 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
a.moving = true;
|
| 1489 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1490 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1491 |
a.facing = a.faceX;
|
|
@@ -1571,10 +1584,16 @@ function step(b, dt) {
|
|
| 1571 |
for (const a of b.actors) {
|
| 1572 |
if (!a.alive || b.over) continue;
|
| 1573 |
const enemy = nearestFoe(b, a);
|
| 1574 |
-
if (!enemy) continue;
|
| 1575 |
-
a.
|
| 1576 |
-
|
| 1577 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1578 |
a.moving = false;
|
| 1579 |
if (isKd(b, a)) {
|
| 1580 |
a.casting = null;
|
|
|
|
| 850 |
prep: null,
|
| 851 |
alive: true,
|
| 852 |
mods: [],
|
| 853 |
+
kd: 0,
|
| 854 |
+
aggroRadius: unit.aggroRadius ?? null
|
| 855 |
+
// optional: idle until a foe is within this distance (else always engage)
|
| 856 |
};
|
| 857 |
}
|
| 858 |
+
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null } = {}) {
|
| 859 |
const actors = [];
|
| 860 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 861 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 862 |
+
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {}, world };
|
| 863 |
}
|
| 864 |
function setInput(b, id, cmd) {
|
| 865 |
if (!b.input) b.input = {};
|
|
|
|
| 1398 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1399 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1400 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1401 |
+
const px = a.x, py = a.y;
|
| 1402 |
+
stepMove(b, a, vx, vy, dt);
|
| 1403 |
a.vx = vx;
|
| 1404 |
a.vy = vy;
|
| 1405 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1406 |
}
|
| 1407 |
var RVO_TAU = 1.6;
|
| 1408 |
var RVO_RANGE = 280;
|
|
|
|
| 1454 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1455 |
}
|
| 1456 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
| 1457 |
+
function stepMove(b, a, vx, vy, dt) {
|
| 1458 |
+
const w = b.world && b.world.walkable;
|
| 1459 |
+
let nx = clampField(a.x + vx * dt, a.radius, FIELD.w);
|
| 1460 |
+
let ny = clampField(a.y + vy * dt, a.radius, FIELD.h);
|
| 1461 |
+
if (w) {
|
| 1462 |
+
if (!w(nx, a.y)) nx = a.x;
|
| 1463 |
+
if (!w(nx, ny)) ny = a.y;
|
| 1464 |
+
}
|
| 1465 |
+
a.x = nx;
|
| 1466 |
+
a.y = ny;
|
| 1467 |
+
}
|
| 1468 |
function resolveOverlaps(b) {
|
| 1469 |
const live = b.actors.filter((a) => a.alive);
|
| 1470 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
|
|
| 1496 |
if (mx || my) {
|
| 1497 |
const len = Math.hypot(mx, my) || 1;
|
| 1498 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1499 |
+
const px = a.x, py = a.y;
|
| 1500 |
+
stepMove(b, a, mx / len * speed, my / len * speed, dt);
|
| 1501 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1502 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1503 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1504 |
a.facing = a.faceX;
|
|
|
|
| 1584 |
for (const a of b.actors) {
|
| 1585 |
if (!a.alive || b.over) continue;
|
| 1586 |
const enemy = nearestFoe(b, a);
|
| 1587 |
+
if (!enemy && a.control !== "player") continue;
|
| 1588 |
+
if (a.aggroRadius != null && a.control !== "player" && dist(a, enemy) > a.aggroRadius) {
|
| 1589 |
+
a.moving = false;
|
| 1590 |
+
continue;
|
| 1591 |
+
}
|
| 1592 |
+
if (enemy) {
|
| 1593 |
+
a.facing = enemy.x < a.x ? -1 : 1;
|
| 1594 |
+
a.faceX = a.facing;
|
| 1595 |
+
a.faceY = enemy.y < a.y ? -1 : 1;
|
| 1596 |
+
}
|
| 1597 |
a.moving = false;
|
| 1598 |
if (isKd(b, a)) {
|
| 1599 |
a.casting = null;
|
|
@@ -824,14 +824,16 @@ function makeActor(unit, team, id, slot) {
|
|
| 824 |
prep: null,
|
| 825 |
alive: true,
|
| 826 |
mods: [],
|
| 827 |
-
kd: 0
|
|
|
|
|
|
|
| 828 |
};
|
| 829 |
}
|
| 830 |
-
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) {
|
| 831 |
const actors = [];
|
| 832 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 833 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 834 |
-
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} };
|
| 835 |
}
|
| 836 |
function setInput(b, id, cmd) {
|
| 837 |
if (!b.input) b.input = {};
|
|
@@ -1370,11 +1372,11 @@ function moveActor(b, a, enemy, dt) {
|
|
| 1370 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1371 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1372 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
a.vx = vx;
|
| 1376 |
a.vy = vy;
|
| 1377 |
-
a.moving = true;
|
| 1378 |
}
|
| 1379 |
var RVO_TAU = 1.6;
|
| 1380 |
var RVO_RANGE = 280;
|
|
@@ -1426,6 +1428,17 @@ function timeToHit(px, py, rvx, rvy, R) {
|
|
| 1426 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1427 |
}
|
| 1428 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1429 |
function resolveOverlaps(b) {
|
| 1430 |
const live = b.actors.filter((a) => a.alive);
|
| 1431 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
@@ -1457,9 +1470,9 @@ function stepPlayer(b, a, foe, dt) {
|
|
| 1457 |
if (mx || my) {
|
| 1458 |
const len = Math.hypot(mx, my) || 1;
|
| 1459 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
a.moving = true;
|
| 1463 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1464 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1465 |
a.facing = a.faceX;
|
|
@@ -1545,10 +1558,16 @@ function step(b, dt) {
|
|
| 1545 |
for (const a of b.actors) {
|
| 1546 |
if (!a.alive || b.over) continue;
|
| 1547 |
const enemy = nearestFoe(b, a);
|
| 1548 |
-
if (!enemy) continue;
|
| 1549 |
-
a.
|
| 1550 |
-
|
| 1551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1552 |
a.moving = false;
|
| 1553 |
if (isKd(b, a)) {
|
| 1554 |
a.casting = null;
|
|
|
|
| 824 |
prep: null,
|
| 825 |
alive: true,
|
| 826 |
mods: [],
|
| 827 |
+
kd: 0,
|
| 828 |
+
aggroRadius: unit.aggroRadius ?? null
|
| 829 |
+
// optional: idle until a foe is within this distance (else always engage)
|
| 830 |
};
|
| 831 |
}
|
| 832 |
+
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null } = {}) {
|
| 833 |
const actors = [];
|
| 834 |
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 835 |
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 836 |
+
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {}, world };
|
| 837 |
}
|
| 838 |
function setInput(b, id, cmd) {
|
| 839 |
if (!b.input) b.input = {};
|
|
|
|
| 1372 |
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1373 |
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1374 |
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1375 |
+
const px = a.x, py = a.y;
|
| 1376 |
+
stepMove(b, a, vx, vy, dt);
|
| 1377 |
a.vx = vx;
|
| 1378 |
a.vy = vy;
|
| 1379 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1380 |
}
|
| 1381 |
var RVO_TAU = 1.6;
|
| 1382 |
var RVO_RANGE = 280;
|
|
|
|
| 1428 |
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1429 |
}
|
| 1430 |
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
| 1431 |
+
function stepMove(b, a, vx, vy, dt) {
|
| 1432 |
+
const w = b.world && b.world.walkable;
|
| 1433 |
+
let nx = clampField(a.x + vx * dt, a.radius, FIELD.w);
|
| 1434 |
+
let ny = clampField(a.y + vy * dt, a.radius, FIELD.h);
|
| 1435 |
+
if (w) {
|
| 1436 |
+
if (!w(nx, a.y)) nx = a.x;
|
| 1437 |
+
if (!w(nx, ny)) ny = a.y;
|
| 1438 |
+
}
|
| 1439 |
+
a.x = nx;
|
| 1440 |
+
a.y = ny;
|
| 1441 |
+
}
|
| 1442 |
function resolveOverlaps(b) {
|
| 1443 |
const live = b.actors.filter((a) => a.alive);
|
| 1444 |
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
|
|
|
| 1470 |
if (mx || my) {
|
| 1471 |
const len = Math.hypot(mx, my) || 1;
|
| 1472 |
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1473 |
+
const px = a.x, py = a.y;
|
| 1474 |
+
stepMove(b, a, mx / len * speed, my / len * speed, dt);
|
| 1475 |
+
a.moving = b.world ? a.x !== px || a.y !== py : true;
|
| 1476 |
a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX;
|
| 1477 |
a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY;
|
| 1478 |
a.facing = a.faceX;
|
|
|
|
| 1558 |
for (const a of b.actors) {
|
| 1559 |
if (!a.alive || b.over) continue;
|
| 1560 |
const enemy = nearestFoe(b, a);
|
| 1561 |
+
if (!enemy && a.control !== "player") continue;
|
| 1562 |
+
if (a.aggroRadius != null && a.control !== "player" && dist(a, enemy) > a.aggroRadius) {
|
| 1563 |
+
a.moving = false;
|
| 1564 |
+
continue;
|
| 1565 |
+
}
|
| 1566 |
+
if (enemy) {
|
| 1567 |
+
a.facing = enemy.x < a.x ? -1 : 1;
|
| 1568 |
+
a.faceX = a.facing;
|
| 1569 |
+
a.faceY = enemy.y < a.y ? -1 : 1;
|
| 1570 |
+
}
|
| 1571 |
a.moving = false;
|
| 1572 |
if (isKd(b, a)) {
|
| 1573 |
a.casting = null;
|
|
@@ -30,6 +30,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 30 |
const keys = /* @__PURE__ */ new Set();
|
| 31 |
const texCache = /* @__PURE__ */ new Map();
|
| 32 |
const listeners = /* @__PURE__ */ new Set();
|
|
|
|
| 33 |
const drag = { on: false, px: 0, py: 0 };
|
| 34 |
const pointers = /* @__PURE__ */ new Map();
|
| 35 |
const pinch = { on: false, dist: 0 };
|
|
@@ -292,8 +293,10 @@ function createChunkedMap(pixi, host, config) {
|
|
| 292 |
canvas.addEventListener("pointermove", handlers.move);
|
| 293 |
window.addEventListener("pointerup", handlers.up);
|
| 294 |
canvas.addEventListener("wheel", handlers.wheel, { passive: false });
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
| 297 |
}
|
| 298 |
function unbindInput() {
|
| 299 |
const canvas = app?.canvas;
|
|
@@ -306,6 +309,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 306 |
}
|
| 307 |
const PAN_SPEED = 6;
|
| 308 |
function tick(ticker) {
|
|
|
|
| 309 |
if (!enabled) return;
|
| 310 |
let dx = 0, dy = 0;
|
| 311 |
for (const k of keys) {
|
|
@@ -371,6 +375,24 @@ function createChunkedMap(pixi, host, config) {
|
|
| 371 |
function getCamera() {
|
| 372 |
return { x: camera.x, y: camera.y, zoom };
|
| 373 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
function tileIndexAt(wx, wy) {
|
| 375 |
if (!config.tileIndexAt) return null;
|
| 376 |
const cx = Math.floor(wx / CHUNK5), cy = Math.floor(wy / CHUNK5);
|
|
@@ -426,7 +448,7 @@ function createChunkedMap(pixi, host, config) {
|
|
| 426 |
ctx = null;
|
| 427 |
texCache.clear();
|
| 428 |
}
|
| 429 |
-
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, zoomBy, setEnabled };
|
| 430 |
}
|
| 431 |
|
| 432 |
// ../auto-battler/src/engine/rng.js
|
|
@@ -1477,7 +1499,7 @@ var fpConfig = (seed) => ({
|
|
| 1477 |
}
|
| 1478 |
});
|
| 1479 |
function createForgottenPlainsMap(pixi, host, opts = {}) {
|
| 1480 |
-
return createChunkedMap(pixi, host, fpConfig(opts.seed ?? 1));
|
| 1481 |
}
|
| 1482 |
|
| 1483 |
// ../auto-battler/src/engine/necropolisGen.js
|
|
|
|
| 30 |
const keys = /* @__PURE__ */ new Set();
|
| 31 |
const texCache = /* @__PURE__ */ new Map();
|
| 32 |
const listeners = /* @__PURE__ */ new Set();
|
| 33 |
+
const tickHooks = /* @__PURE__ */ new Set();
|
| 34 |
const drag = { on: false, px: 0, py: 0 };
|
| 35 |
const pointers = /* @__PURE__ */ new Map();
|
| 36 |
const pinch = { on: false, dist: 0 };
|
|
|
|
| 293 |
canvas.addEventListener("pointermove", handlers.move);
|
| 294 |
window.addEventListener("pointerup", handlers.up);
|
| 295 |
canvas.addEventListener("wheel", handlers.wheel, { passive: false });
|
| 296 |
+
if (config.keyboardPan !== false) {
|
| 297 |
+
window.addEventListener("keydown", handlers.keydown);
|
| 298 |
+
window.addEventListener("keyup", handlers.keyup);
|
| 299 |
+
}
|
| 300 |
}
|
| 301 |
function unbindInput() {
|
| 302 |
const canvas = app?.canvas;
|
|
|
|
| 309 |
}
|
| 310 |
const PAN_SPEED = 6;
|
| 311 |
function tick(ticker) {
|
| 312 |
+
for (const fn of tickHooks) fn(ticker);
|
| 313 |
if (!enabled) return;
|
| 314 |
let dx = 0, dy = 0;
|
| 315 |
for (const k of keys) {
|
|
|
|
| 375 |
function getCamera() {
|
| 376 |
return { x: camera.x, y: camera.y, zoom };
|
| 377 |
}
|
| 378 |
+
function onTick(fn) {
|
| 379 |
+
tickHooks.add(fn);
|
| 380 |
+
return () => tickHooks.delete(fn);
|
| 381 |
+
}
|
| 382 |
+
function getEntityLayer() {
|
| 383 |
+
return propLayer;
|
| 384 |
+
}
|
| 385 |
+
function getApp() {
|
| 386 |
+
return app;
|
| 387 |
+
}
|
| 388 |
+
function screenToWorld(sx, sy) {
|
| 389 |
+
if (!app) return { x: 0, y: 0 };
|
| 390 |
+
return { x: camera.x + (sx - app.screen.width / 2) / zoom, y: camera.y + (sy - app.screen.height / 2) / zoom };
|
| 391 |
+
}
|
| 392 |
+
function worldToScreen(wx, wy) {
|
| 393 |
+
if (!app) return { x: 0, y: 0 };
|
| 394 |
+
return { x: app.screen.width / 2 + (wx - camera.x) * zoom, y: app.screen.height / 2 + (wy - camera.y) * zoom };
|
| 395 |
+
}
|
| 396 |
function tileIndexAt(wx, wy) {
|
| 397 |
if (!config.tileIndexAt) return null;
|
| 398 |
const cx = Math.floor(wx / CHUNK5), cy = Math.floor(wy / CHUNK5);
|
|
|
|
| 448 |
ctx = null;
|
| 449 |
texCache.clear();
|
| 450 |
}
|
| 451 |
+
return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
|
| 452 |
}
|
| 453 |
|
| 454 |
// ../auto-battler/src/engine/rng.js
|
|
|
|
| 1499 |
}
|
| 1500 |
});
|
| 1501 |
function createForgottenPlainsMap(pixi, host, opts = {}) {
|
| 1502 |
+
return createChunkedMap(pixi, host, { ...fpConfig(opts.seed ?? 1), keyboardPan: opts.keyboardPan });
|
| 1503 |
}
|
| 1504 |
|
| 1505 |
// ../auto-battler/src/engine/necropolisGen.js
|
|
@@ -20,7 +20,7 @@ const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
| 20 |
// Each class shows the idle pose of a fitting sprite (character slug → see
|
| 21 |
// characters.json). The sheet's front-right row is animated as a tiny looping
|
| 22 |
// icon beside the class name in the dropdown.
|
| 23 |
-
const CLASS_SLUG = {
|
| 24 |
Warrior: 'true-heroes-iii-fighter',
|
| 25 |
Ranger: 'true-heroes-iii-ranger',
|
| 26 |
Monk: 'true-heroes-ii-bard',
|
|
|
|
| 20 |
// Each class shows the idle pose of a fitting sprite (character slug → see
|
| 21 |
// characters.json). The sheet's front-right row is animated as a tiny looping
|
| 22 |
// icon beside the class name in the dropdown.
|
| 23 |
+
export const CLASS_SLUG = {
|
| 24 |
Warrior: 'true-heroes-iii-fighter',
|
| 25 |
Ranger: 'true-heroes-iii-ranger',
|
| 26 |
Monk: 'true-heroes-ii-bard',
|
|
@@ -5,7 +5,7 @@
|
|
| 5 |
{
|
| 6 |
"title": "World",
|
| 7 |
"items": [
|
| 8 |
-
{ "label": "
|
| 9 |
]
|
| 10 |
},
|
| 11 |
{
|
|
|
|
| 5 |
{
|
| 6 |
"title": "World",
|
| 7 |
"items": [
|
| 8 |
+
{ "label": "Game", "icon": "◆", "href": "#/stage", "view": "stage", "space": "Battle" }
|
| 9 |
]
|
| 10 |
},
|
| 11 |
{
|
|
@@ -4,13 +4,13 @@
|
|
| 4 |
// (src/render/spriteSheet.js → /web/sheet.js). Pixi is injected (CDN) so there's
|
| 5 |
// one Pixi instance.
|
| 6 |
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
|
| 7 |
-
import { makeTeamBattle, step, FIELD } from '/web/engine.js'
|
| 8 |
-
import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js'
|
| 9 |
import { mountSpritePlayground } from '/web/playground.js'
|
| 10 |
import { mountClassesSandbox } from '/web/classesSandbox.js'
|
| 11 |
import { mountEnemiesSandbox } from '/web/enemiesSandbox.js'
|
| 12 |
import { mountMapSandbox } from '/web/mapSandbox.js'
|
| 13 |
-
import {
|
|
|
|
|
|
|
| 14 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 15 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 16 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
@@ -72,13 +72,10 @@ async function loadChars() {
|
|
| 72 |
for (const p of d.packs || []) for (const c of p.characters || []) charMap[c.slug] = c
|
| 73 |
return charMap
|
| 74 |
}
|
| 75 |
-
const loadSheet = async (url) => {
|
| 76 |
-
const t = await PIXI.Assets.load(spriteUrl(url)); t.source.scaleMode = 'nearest'; return t
|
| 77 |
-
}
|
| 78 |
|
| 79 |
-
let playground = null,
|
| 80 |
window.tinyResize = () => {
|
| 81 |
-
try {
|
| 82 |
// Re-fit + re-centre: the sprite stage mounts in a hidden (0-size) tab, so the
|
| 83 |
// character must be re-placed once the tab is actually shown.
|
| 84 |
try { if (playground) { playground.resize(); playground.recenter() } } catch {}
|
|
@@ -154,86 +151,38 @@ window.tacNavigate = function (target) {
|
|
| 154 |
if (_tacNav) _tacNav(target)
|
| 155 |
}
|
| 156 |
|
| 157 |
-
// ──
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
{ name: '
|
| 166 |
-
{ name: '
|
| 167 |
-
{ name: '
|
| 168 |
-
{ name: '
|
|
|
|
| 169 |
]
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
const idle = await loadSheet(c.idle)
|
| 174 |
-
const cell = cellOf(idle.source.height)
|
| 175 |
-
const grid = async (u) => (u ? sliceGridWith(PIXI, await loadSheet(u), cell) : null)
|
| 176 |
-
return { cell, idle: sliceGridWith(PIXI, idle, cell), walk: await grid(c.walk), die: await grid(c.die) }
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
whenEl('battle-stage', async (el) => {
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
const
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
const layer = new PIXI.Container(); layer.sortableChildren = true
|
| 190 |
-
const bars = new PIXI.Graphics()
|
| 191 |
-
app.stage.addChild(layer, bars)
|
| 192 |
-
const view = {}
|
| 193 |
-
let battle, seed = 1, overAt = 0
|
| 194 |
-
function fresh() {
|
| 195 |
-
battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0
|
| 196 |
-
for (const id in view) { const v = view[id]; v.dead = false; v.state = null; v.facing = null; v.sp.onComplete = null; v.sp.alpha = 1 }
|
| 197 |
-
}
|
| 198 |
-
function ensureView(a) {
|
| 199 |
-
if (view[a.id]) return view[a.id]
|
| 200 |
-
const sh = sheets[slugById[a.id]]
|
| 201 |
-
const sp = new PIXI.AnimatedSprite(sh ? rowFor(sh.idle, 'front-right') : [PIXI.Texture.WHITE])
|
| 202 |
-
sp.anchor.set(0.5, 0.9); sp.animationSpeed = ANIM.idle; sp.play(); layer.addChild(sp)
|
| 203 |
-
return (view[a.id] = { sp, sh, state: null, facing: null, dead: false })
|
| 204 |
}
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
bars.clear()
|
| 212 |
-
const base = Math.min(Math.max(W / 190, 1.6), 3)
|
| 213 |
-
for (const a of battle.actors) {
|
| 214 |
-
const v = ensureView(a); if (!v.sh) continue
|
| 215 |
-
const cx = a.x * sx, cy = a.y * sy
|
| 216 |
-
const depth = base * (0.85 + 0.4 * (a.y / FIELD.h))
|
| 217 |
-
v.sp.position.set(cx, cy); v.sp.zIndex = a.y; v.sp.scale.set(depth)
|
| 218 |
-
const facing = facingFor(a.faceX, a.faceY)
|
| 219 |
-
if (!a.alive) {
|
| 220 |
-
if (!v.dead) {
|
| 221 |
-
v.dead = true; const f = rowFor(v.sh.die || v.sh.idle, facing)
|
| 222 |
-
v.sp.loop = false; v.sp.textures = f; v.sp.animationSpeed = ANIM.die * 0.75; v.sp.alpha = 0.92
|
| 223 |
-
v.sp.onComplete = () => v.sp.gotoAndStop(f.length - 1); v.sp.gotoAndPlay(0)
|
| 224 |
-
}
|
| 225 |
-
continue
|
| 226 |
-
}
|
| 227 |
-
const mode = a.moving ? 'walk' : 'idle'
|
| 228 |
-
if (v.state !== mode || v.facing !== facing) {
|
| 229 |
-
v.state = mode; v.facing = facing
|
| 230 |
-
v.sp.loop = true; v.sp.textures = rowFor(v.sh[mode] || v.sh.idle, facing)
|
| 231 |
-
v.sp.animationSpeed = ANIM[mode]; v.sp.play()
|
| 232 |
-
}
|
| 233 |
-
const r = v.sh.cell * depth * 0.45, top = cy - v.sh.cell * depth - 4
|
| 234 |
-
const hp = Math.max(0, a.hp) / a.maxHp
|
| 235 |
-
bars.rect(cx - r, top, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 })
|
| 236 |
-
bars.rect(cx - r, top, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e)
|
| 237 |
-
}
|
| 238 |
-
})
|
| 239 |
})
|
|
|
|
| 4 |
// (src/render/spriteSheet.js → /web/sheet.js). Pixi is injected (CDN) so there's
|
| 5 |
// one Pixi instance.
|
| 6 |
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
|
|
|
|
|
|
|
| 7 |
import { mountSpritePlayground } from '/web/playground.js'
|
| 8 |
import { mountClassesSandbox } from '/web/classesSandbox.js'
|
| 9 |
import { mountEnemiesSandbox } from '/web/enemiesSandbox.js'
|
| 10 |
import { mountMapSandbox } from '/web/mapSandbox.js'
|
| 11 |
+
import { mountComboBattler } from '/web/comboBattler.js'
|
| 12 |
+
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
|
| 13 |
+
import { listPersonas } from '/web/personaStore.js'
|
| 14 |
import { mountDiaryPanel } from '/web/diaryPanel.js'
|
| 15 |
import { mountSettingsPanel } from '/web/settingsPanel.js'
|
| 16 |
import { mountSkillForgePanel } from '/web/skillForgePanel.js'
|
|
|
|
| 72 |
for (const p of d.packs || []) for (const c of p.characters || []) charMap[c.slug] = c
|
| 73 |
return charMap
|
| 74 |
}
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
let playground = null, comboCtrl = null
|
| 77 |
window.tinyResize = () => {
|
| 78 |
+
try { comboCtrl?.resize?.() } catch {}
|
| 79 |
// Re-fit + re-centre: the sprite stage mounts in a hidden (0-size) tab, so the
|
| 80 |
// character must be re-placed once the tab is actually shown.
|
| 81 |
try { if (playground) { playground.resize(); playground.recenter() } } catch {}
|
|
|
|
| 151 |
if (_tacNav) _tacNav(target)
|
| 152 |
}
|
| 153 |
|
| 154 |
+
// ── Game tab (#battle-stage) — Forgotten Plains roam + on-map combat ──────────
|
| 155 |
+
// Drops a persona from the roster onto the map, lets it wander (A*), and fights enemies that aggro
|
| 156 |
+
// on proximity in real-time on the map. The old hardcoded 4v4 demo lived here; this is the shared
|
| 157 |
+
// comboBattler surface (auto-battler source, bundled to /web/comboBattler.js).
|
| 158 |
+
const sheetsOf = (c) => ({ idle: spriteUrl(c.idle), walk: spriteUrl(c.walk), attack: spriteUrl(c.attack), dmg: spriteUrl(c.dmg), die: spriteUrl(c.die) })
|
| 159 |
+
// An enemy band (character slug + combat stats), resolved against characters.json.
|
| 160 |
+
const GAME_ENEMIES = [
|
| 161 |
+
{ name: 'Orc Blade', slug: 'dark-orc-army-orc-blade', stats: { hp: 130, armor: 25, basicDamage: 13 }, attackType: 'melee' },
|
| 162 |
+
{ name: 'Orc Raider', slug: 'dark-orc-army-orc-raider', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
|
| 163 |
+
{ name: 'Orc Scout', slug: 'dark-orc-army-orc-scout', stats: { hp: 90, armor: 12, basicDamage: 10 }, attackType: 'melee' },
|
| 164 |
+
{ name: 'Reaver', slug: 'dark-brotherhood-devoted-blade', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
|
| 165 |
+
{ name: 'Acolyte', slug: 'dark-brotherhood-acolyte', stats: { hp: 100, armor: 15, basicDamage: 10 }, attackType: 'melee' },
|
| 166 |
+
{ name: 'Berserker', slug: 'dark-orc-army-feral-berserker', stats: { hp: 160, armor: 30, basicDamage: 16 }, attackType: 'melee' },
|
| 167 |
]
|
| 168 |
+
// Persona class → engine profession (the engine has templates + skills for these five).
|
| 169 |
+
const PERSONA_PROF = { Warrior: 'Warrior', Ranger: 'Ranger', Monk: 'Monk', Assassin: 'Assassin', Mage: 'Necromancer', Paladin: 'Monk', Cleric: 'Monk', Knight: 'Warrior' }
|
| 170 |
+
const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player is this near
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
whenEl('battle-stage', async (el) => {
|
| 172 |
+
unprose(el)
|
| 173 |
+
const chars = await loadChars()
|
| 174 |
+
// Active persona → class → character sheets + a directly-controlled hero (WASD + keys).
|
| 175 |
+
const p = listPersonas()[0] || null
|
| 176 |
+
const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
|
| 177 |
+
const player = {
|
| 178 |
+
name: p?.name || pc?.name || 'Hero',
|
| 179 |
+
sheets: sheetsOf(pc),
|
| 180 |
+
unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }, // skills filled from profession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
| 182 |
+
const enemies = GAME_ENEMIES.map((e) => {
|
| 183 |
+
const c = chars[e.slug]; if (!c) return null
|
| 184 |
+
return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO } }
|
| 185 |
+
}).filter(Boolean)
|
| 186 |
+
comboCtrl = mountComboBattler(PIXI, el, { seed: 1, player, enemies })
|
| 187 |
+
await comboCtrl.ready
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
})
|