polats Claude Opus 4.8 (1M context) commited on
Commit
5bdce49
·
1 Parent(s): 6d04e7b

Game page: directly-controlled Forgotten Plains battle; rename to "Game"

Browse files

Replace 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 CHANGED
@@ -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:
build.sh CHANGED
@@ -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.
requirements.txt CHANGED
@@ -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.
web/classesSandbox.js CHANGED
@@ -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
- a.x = clampField(a.x + vx * dt, a.radius, FIELD.w);
1567
- a.y = clampField(a.y + vy * dt, a.radius, FIELD.h);
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
- a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w);
1654
- a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h);
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.facing = enemy.x < a.x ? -1 : 1;
1743
- a.faceX = a.facing;
1744
- a.faceY = enemy.y < a.y ? -1 : 1;
 
 
 
 
 
 
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;
web/comboBattler.js ADDED
The diff for this file is too large to render. See raw diff
 
web/enemiesSandbox.js CHANGED
@@ -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
- a.x = clampField(a.x + vx * dt, a.radius, FIELD.w);
1400
- a.y = clampField(a.y + vy * dt, a.radius, FIELD.h);
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
- a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w);
1487
- a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h);
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.facing = enemy.x < a.x ? -1 : 1;
1576
- a.faceX = a.facing;
1577
- a.faceY = enemy.y < a.y ? -1 : 1;
 
 
 
 
 
 
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;
web/engine.js CHANGED
@@ -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
- a.x = clampField(a.x + vx * dt, a.radius, FIELD.w);
1374
- a.y = clampField(a.y + vy * dt, a.radius, FIELD.h);
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
- a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w);
1461
- a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h);
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.facing = enemy.x < a.x ? -1 : 1;
1550
- a.faceX = a.facing;
1551
- a.faceY = enemy.y < a.y ? -1 : 1;
 
 
 
 
 
 
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;
web/mapSandbox.js CHANGED
@@ -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
- window.addEventListener("keydown", handlers.keydown);
296
- window.addEventListener("keyup", handlers.keyup);
 
 
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
web/personaPanel.js CHANGED
@@ -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',
web/shell/nav.json CHANGED
@@ -5,7 +5,7 @@
5
  {
6
  "title": "World",
7
  "items": [
8
- { "label": "Combo-Battler", "icon": "◆", "href": "#/stage", "view": "stage", "space": "Battle" }
9
  ]
10
  },
11
  {
 
5
  {
6
  "title": "World",
7
  "items": [
8
+ { "label": "Game", "icon": "◆", "href": "#/stage", "view": "stage", "space": "Battle" }
9
  ]
10
  },
11
  {
web/tiny.js CHANGED
@@ -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 { mountPersonaPanel } from '/web/personaPanel.js'
 
 
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, battleApp = null
80
  window.tinyResize = () => {
81
- try { battleApp && battleApp.resize() } catch {}
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
- // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
158
- const PLAYERS = [
159
- { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },
160
- { profession: 'Ranger', name: 'Sela', skills: [], slug: 'true-heroes-iii-ranger' },
161
- { profession: 'Monk', name: 'Oda', skills: [], slug: 'true-heroes-ii-cleric' },
162
- { profession: 'Assassin', name: 'Vex', skills: [], slug: 'true-heroes-iv-ninja-assassin' },
163
- ]
164
- const ENEMIES = [
165
- { name: 'Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-devoted-blade' },
166
- { name: 'Acolyte', stats: { hp: 300, armor: 30, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'dark-brotherhood-acolyte' },
167
- { name: 'Blood Mage', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [], slug: 'true-heroes-iv-blood-mage' },
168
- { name: 'Knight', stats: { hp: 360, armor: 40, basicDamage: 14 }, attackType: 'melee', skills: [], slug: 'rts-humans-knight' },
 
169
  ]
170
-
171
- async function loadUnitSheets(slug) {
172
- const c = (await loadChars())[slug]; if (!c) return null
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
- const app = new PIXI.Application()
181
- await app.init({ background: 0x0b0e12, resizeTo: el, antialias: false })
182
- el.appendChild(app.canvas)
183
- battleApp = app
184
- const slugById = {}
185
- PLAYERS.forEach((p, i) => { slugById['P' + i] = p.slug })
186
- ENEMIES.forEach((e, i) => { slugById['E' + i] = e.slug })
187
- const sheets = {}
188
- for (const s of new Set(Object.values(slugById))) sheets[s] = await loadUnitSheets(s)
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
- fresh()
206
- app.ticker.add(() => {
207
- const W = app.screen.width, H = app.screen.height, sx = W / FIELD.w, sy = H / FIELD.h
208
- if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) }
209
- else if (!overAt) overAt = performance.now()
210
- if (overAt && performance.now() - overAt > 3000) fresh()
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
  })