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

Game: free-roam overworld with biome-themed enemy spawning

Browse files

Rebuild the Game page from a fixed arena into a free-roam map: the hero roams the whole three-band overworld and enemies spawn around it as you explore — RTS humans on the Forgotten Plains, orcs in the Orc Kingdom, the Dark Brotherhood in the Necropolis — culled as you move on.

- web/*.js: rebuilt bundles (comboBattler + map/classes/enemies sandboxes) from auto-battler.
- web/tiny.js: per-biome enemy rosters wired into the Game page.
- app.py: full-screen #battle-stage; relocate Gradio footer links into the sidebar.

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

Files changed (6) hide show
  1. app.py +28 -9
  2. web/classesSandbox.js +40 -14
  3. web/comboBattler.js +1483 -198
  4. web/enemiesSandbox.js +40 -14
  5. web/mapSandbox.js +60 -10
  6. web/tiny.js +54 -11
app.py CHANGED
@@ -122,12 +122,23 @@ THEME = ('<style>'
122
  # Gradio still hides it (display:none on the inactive tab's ancestor).
123
  '.gradio-container .tabitem{padding:0 !important;}'
124
  '.gradio-container .tabs{border:0 !important;}'
125
- '#sprite-stage,#persona-stage,#diary-stage,#classes-stage,#enemies-stage,#worldmap-stage{position:fixed !important;top:0;bottom:0;'
126
  'right:0;left:var(--tac-w,240px);height:auto !important;z-index:1;}'
127
  'body.tac-collapsed #sprite-stage,body.tac-collapsed #persona-stage,'
128
  'body.tac-collapsed #diary-stage,body.tac-collapsed #classes-stage,body.tac-collapsed #enemies-stage,'
129
- 'body.tac-collapsed #worldmap-stage{left:0;}'
130
- '@media (max-width:768px){#sprite-stage,#persona-stage,#diary-stage,#classes-stage,#enemies-stage,#worldmap-stage{left:0;}}'
 
 
 
 
 
 
 
 
 
 
 
131
  '</style>')
132
  # `upgrade-insecure-requests` is needed on the HTTPS Space (prevents mixed-content behind HF's
133
  # TLS edge) but BREAKS plain-http LAN testing: it forces every asset/manifest/frame URL to https
@@ -145,7 +156,9 @@ HEAD = (_CSP
145
  '<link rel="stylesheet" href="/web/shell/worldmap.css">'
146
  '<script type="module" src="/web/tiny.js"></script>'
147
  '<script src="/web/shell/sidebar.js"></script>')
148
- STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
 
 
149
 
150
 
151
  # Shared app-shell sidebar: rendered from the SAME nav.json + sidebar.css +
@@ -176,11 +189,17 @@ def build_sidebar(nav):
176
  out.append('<div class="tac-section">')
177
  if sec.get("title"):
178
  out.append(f'<div class="tac-section-title">{sec["title"]}</div>')
179
- for it in items:
180
- cls = "tac-nav-item active" if first else "tac-nav-item"
181
- first = False
182
- out.append(f'<a class="{cls}" data-target="{it["space"]}" href="#">'
183
- f'<span class="tac-ico">{it.get("icon","")}</span><span>{it["label"]}</span></a>')
 
 
 
 
 
 
184
  out.append('</div>')
185
  out.append('</aside>')
186
  out.append('<button class="tac-toggle tac-reopen" title="Open menu">›</button>')
 
122
  # Gradio still hides it (display:none on the inactive tab's ancestor).
123
  '.gradio-container .tabitem{padding:0 !important;}'
124
  '.gradio-container .tabs{border:0 !important;}'
125
+ '#sprite-stage,#persona-stage,#diary-stage,#classes-stage,#enemies-stage,#worldmap-stage,#battle-stage{position:fixed !important;top:0;bottom:0;'
126
  'right:0;left:var(--tac-w,240px);height:auto !important;z-index:1;}'
127
  'body.tac-collapsed #sprite-stage,body.tac-collapsed #persona-stage,'
128
  'body.tac-collapsed #diary-stage,body.tac-collapsed #classes-stage,body.tac-collapsed #enemies-stage,'
129
+ 'body.tac-collapsed #worldmap-stage,body.tac-collapsed #battle-stage{left:0;}'
130
+ '@media (max-width:768px){#sprite-stage,#persona-stage,#diary-stage,#classes-stage,#enemies-stage,#worldmap-stage,#battle-stage{left:0;}}'
131
+ # Gradio's relocated footer links (Use via API / Built with Gradio / Settings), styled to look
132
+ # like the sidebar nav items WITHOUT the tac-nav-item class — that class makes sidebar.js hijack
133
+ # the click (mark them active = white, and swallow navigation). web/tiny.js copies the reference
134
+ # nav item's padding onto them; here we mirror the rest (icon on the left, hover, colour).
135
+ '#tac-extlinks .tac-extlink{display:flex !important;align-items:center;gap:8px !important;width:100%;'
136
+ 'min-height:0 !important;background:none !important;border:0 !important;border-radius:0 !important;'
137
+ 'box-shadow:none !important;justify-content:flex-start !important;text-align:left !important;'
138
+ 'color:var(--tac-ink) !important;font-family:var(--tac-font) !important;font-size:14px !important;'
139
+ 'font-weight:500 !important;text-decoration:none !important;cursor:pointer;}'
140
+ '#tac-extlinks .tac-extlink:hover{background:var(--tac-bg-2) !important;}'
141
+ '#tac-extlinks .tac-extlink img,#tac-extlinks .tac-extlink svg{order:-1;width:18px !important;height:18px !important;flex-shrink:0;margin:0 !important;}'
142
  '</style>')
143
  # `upgrade-insecure-requests` is needed on the HTTPS Space (prevents mixed-content behind HF's
144
  # TLS edge) but BREAKS plain-http LAN testing: it forces every asset/manifest/frame URL to https
 
156
  '<link rel="stylesheet" href="/web/shell/worldmap.css">'
157
  '<script type="module" src="/web/tiny.js"></script>'
158
  '<script src="/web/shell/sidebar.js"></script>')
159
+ # The Game stage fills the whole content area (full-screen map), like the other stages — the
160
+ # `#battle-stage` rules above lift it out of Gradio's flow; this just sets the load-time background.
161
+ STAGE = "background:#0b0e12"
162
 
163
 
164
  # Shared app-shell sidebar: rendered from the SAME nav.json + sidebar.css +
 
189
  out.append('<div class="tac-section">')
190
  if sec.get("title"):
191
  out.append(f'<div class="tac-section-title">{sec["title"]}</div>')
192
+ if sec.get("title") == "App":
193
+ # The App section is filled entirely by web/tiny.js, which relocates Gradio's footer links
194
+ # (Use via API, Built with Gradio, Settings) here — so all three share the same treatment
195
+ # instead of Settings being a special nav item routed back to the footer.
196
+ out.append('<div id="tac-extlinks"></div>')
197
+ else:
198
+ for it in items:
199
+ cls = "tac-nav-item active" if first else "tac-nav-item"
200
+ first = False
201
+ out.append(f'<a class="{cls}" data-target="{it["space"]}" href="#">'
202
+ f'<span class="tac-ico">{it.get("icon","")}</span><span>{it["label"]}</span></a>')
203
  out.append('</div>')
204
  out.append('</aside>')
205
  out.append('<button class="tac-toggle tac-reopen" title="Open menu">›</button>')
web/classesSandbox.js CHANGED
@@ -1022,11 +1022,11 @@ function makeActor(unit, team, id, slot) {
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 = {};
@@ -1319,9 +1319,10 @@ function applyEffect(b, src, tgt, e, delivery = "spell", s = null) {
1319
  }
1320
  }
1321
  function shadowStep(b, a, tgt) {
 
1322
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
1323
- a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
1324
- a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
1325
  }
1326
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
1327
  const base = dist(src, tgt) / 900;
@@ -1622,9 +1623,10 @@ function timeToHit(px, py, rvx, rvy, R) {
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;
@@ -1633,6 +1635,7 @@ function stepMove(b, a, vx, vy, dt) {
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++) {
1638
  for (let i = 0; i < live.length; i++) {
@@ -1648,10 +1651,10 @@ function resolveOverlaps(b) {
1648
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
1649
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
1650
  const yPush = uy / COLLISION_Y_WEIGHT;
1651
- a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w);
1652
- a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h);
1653
- o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w);
1654
- o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h);
1655
  }
1656
  }
1657
  }
@@ -2063,10 +2066,10 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2063
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
2064
  const floats = [];
2065
  const sheetsById = {};
2066
- for (const [id, def] of Object.entries(defsById)) {
2067
  if (!def?.idle) {
2068
  sheetsById[id] = null;
2069
- continue;
2070
  }
2071
  try {
2072
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
@@ -2092,6 +2095,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2092
  sheetsById[id] = null;
2093
  }
2094
  }
 
2095
  const skillIcons = {};
2096
  for (const s of CB_SKILLS) {
2097
  try {
@@ -2127,7 +2131,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2127
  } catch {
2128
  }
2129
  const skillPlay = {};
2130
- for (const [id, def] of Object.entries(defsById)) {
2131
  const cell = sheetsById[id]?.cell;
2132
  const map = {};
2133
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
@@ -2152,8 +2156,9 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2152
  }
2153
  skillPlay[id] = map;
2154
  }
 
2155
  const view = {};
2156
- for (const [id, def] of Object.entries(defsById)) {
2157
  const sh = sheetsById[id];
2158
  const c = new Container();
2159
  let sp;
@@ -2202,6 +2207,25 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2202
  units.addChild(c);
2203
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
2204
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2205
  function setLoop(v, mode, facing) {
2206
  if (v.mode === mode && v.facing === facing) return;
2207
  v.mode = mode;
@@ -2577,6 +2601,8 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2577
  floats,
2578
  actorOf,
2579
  skillPlay,
 
 
2580
  setLoop,
2581
  playOnce,
2582
  resume,
 
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, field = 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, field: field || FIELD };
1030
  }
1031
  function setInput(b, id, cmd) {
1032
  if (!b.input) b.input = {};
 
1319
  }
1320
  }
1321
  function shadowStep(b, a, tgt) {
1322
+ const f = b.field || FIELD;
1323
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
1324
+ a.x = Math.max(0, Math.min(f.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
1325
+ a.y = Math.max(0, Math.min(f.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
1326
  }
1327
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
1328
  const base = dist(src, tgt) / 900;
 
1623
  }
1624
  var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
1625
  function stepMove(b, a, vx, vy, dt) {
1626
+ const f = b.field || FIELD;
1627
  const w = b.world && b.world.walkable;
1628
+ let nx = clampField(a.x + vx * dt, a.radius, f.w);
1629
+ let ny = clampField(a.y + vy * dt, a.radius, f.h);
1630
  if (w) {
1631
  if (!w(nx, a.y)) nx = a.x;
1632
  if (!w(nx, ny)) ny = a.y;
 
1635
  a.y = ny;
1636
  }
1637
  function resolveOverlaps(b) {
1638
+ const f = b.field || FIELD;
1639
  const live = b.actors.filter((a) => a.alive);
1640
  for (let it = 0; it < DEOVERLAP_ITERS; it++) {
1641
  for (let i = 0; i < live.length; i++) {
 
1651
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
1652
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
1653
  const yPush = uy / COLLISION_Y_WEIGHT;
1654
+ a.x = clampField(a.x - ux * push * aShare, a.radius, f.w);
1655
+ a.y = clampField(a.y - yPush * push * aShare, a.radius, f.h);
1656
+ o.x = clampField(o.x + ux * push * oShare, o.radius, f.w);
1657
+ o.y = clampField(o.y + yPush * push * oShare, o.radius, f.h);
1658
  }
1659
  }
1660
  }
 
2066
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
2067
  const floats = [];
2068
  const sheetsById = {};
2069
+ async function loadSheets(id, def) {
2070
  if (!def?.idle) {
2071
  sheetsById[id] = null;
2072
+ return;
2073
  }
2074
  try {
2075
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
 
2095
  sheetsById[id] = null;
2096
  }
2097
  }
2098
+ for (const [id, def] of Object.entries(defsById)) await loadSheets(id, def);
2099
  const skillIcons = {};
2100
  for (const s of CB_SKILLS) {
2101
  try {
 
2131
  } catch {
2132
  }
2133
  const skillPlay = {};
2134
+ async function buildSkillPlay(id, def) {
2135
  const cell = sheetsById[id]?.cell;
2136
  const map = {};
2137
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
 
2156
  }
2157
  skillPlay[id] = map;
2158
  }
2159
+ for (const [id, def] of Object.entries(defsById)) await buildSkillPlay(id, def);
2160
  const view = {};
2161
+ function buildView(id, def) {
2162
  const sh = sheetsById[id];
2163
  const c = new Container();
2164
  let sp;
 
2207
  units.addChild(c);
2208
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
2209
  }
2210
+ for (const [id, def] of Object.entries(defsById)) buildView(id, def);
2211
+ async function addActor(id, def) {
2212
+ if (view[id]) return view[id];
2213
+ defsById[id] = def;
2214
+ await loadSheets(id, def);
2215
+ await buildSkillPlay(id, def);
2216
+ buildView(id, def);
2217
+ return view[id];
2218
+ }
2219
+ function removeActor(id) {
2220
+ const v = view[id];
2221
+ if (!v) return;
2222
+ units.removeChild(v.container);
2223
+ v.container.destroy({ children: true });
2224
+ delete view[id];
2225
+ delete sheetsById[id];
2226
+ delete skillPlay[id];
2227
+ delete defsById[id];
2228
+ }
2229
  function setLoop(v, mode, facing) {
2230
  if (v.mode === mode && v.facing === facing) return;
2231
  v.mode = mode;
 
2601
  floats,
2602
  actorOf,
2603
  skillPlay,
2604
+ addActor,
2605
+ removeActor,
2606
  setLoop,
2607
  playOnce,
2608
  resume,
web/comboBattler.js CHANGED
@@ -1,84 +1,9 @@
1
- // ../auto-battler/src/engine/rng.js
2
- function makeRng(seed) {
3
- let a = seed >>> 0;
4
- return function rng() {
5
- a |= 0;
6
- a = a + 1831565813 | 0;
7
- let t = Math.imul(a ^ a >>> 15, 1 | a);
8
- t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
9
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
10
- };
11
- }
12
-
13
- // ../auto-battler/src/engine/worldgen.js
14
- function hash2(seed, x, y) {
15
- let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 2654435761);
16
- h = Math.imul(h ^ h >>> 13, 1274126177);
17
- h ^= h >>> 16;
18
- return (h >>> 0) / 4294967296;
19
- }
20
- var smooth = (t) => t * t * (3 - 2 * t);
21
- var lerp = (a, b, t) => a + (b - a) * t;
22
- function valueNoise(seed, x, y) {
23
- const xi = Math.floor(x), yi = Math.floor(y);
24
- const xf = x - xi, yf = y - yi;
25
- const v00 = hash2(seed, xi, yi), v10 = hash2(seed, xi + 1, yi);
26
- const v01 = hash2(seed, xi, yi + 1), v11 = hash2(seed, xi + 1, yi + 1);
27
- const u = smooth(xf), v = smooth(yf);
28
- return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
29
- }
30
- function fbm(seed, x, y, octaves = 5, lacunarity = 2, gain = 0.5) {
31
- let amp = 1, freq = 1, sum = 0, norm = 0;
32
- for (let o = 0; o < octaves; o++) {
33
- sum += amp * valueNoise(seed + o * 1013, x * freq, y * freq);
34
- norm += amp;
35
- amp *= gain;
36
- freq *= lacunarity;
37
- }
38
- return sum / norm;
39
- }
40
-
41
- // ../auto-battler/src/engine/fpGen.js
42
- var sub = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
43
- var DIRT_SCALE = 0.04;
44
- var DIRT_THRESHOLD = 0.56;
45
- function isDirt(seed, x, y) {
46
- return fbm(sub(seed, 53543), x * DIRT_SCALE, y * DIRT_SCALE) > DIRT_THRESHOLD;
47
- }
48
- var STONE_SCALE = 0.085;
49
- var STONE_THRESHOLD = 0.66;
50
- function isStone(seed, x, y) {
51
- return fbm(sub(seed, 22286), x * STONE_SCALE, y * STONE_SCALE) > STONE_THRESHOLD;
52
- }
53
- var FOREST_SCALE = 0.05;
54
- function forestField(seed, x, y) {
55
- return fbm(sub(seed, 15740503), x * FOREST_SCALE, y * FOREST_SCALE);
56
- }
57
- var STONE_CLUMP_SCALE = 0.11;
58
- function stoneClumpField(seed, x, y) {
59
- return fbm(sub(seed, 5702849), x * STONE_CLUMP_SCALE, y * STONE_CLUMP_SCALE);
60
- }
61
- var ELEV_SCALE = 0.025;
62
- var ELEV_THRESHOLD = 0.6;
63
- function isRaised(seed, x, y) {
64
- return fbm(sub(seed, 57836), x * ELEV_SCALE, y * ELEV_SCALE) > ELEV_THRESHOLD;
65
- }
66
- var RIVER_SCALE = 0.022;
67
- var RIVER_WIDTH = 0.035;
68
- var WARP_SCALE = 0.03;
69
- var WARP_AMP = 22;
70
- function isRiver(seed, x, y) {
71
- const wx = x + WARP_AMP * (fbm(sub(seed, 1297), x * WARP_SCALE, y * WARP_SCALE) - 0.5);
72
- const wy = y + WARP_AMP * (fbm(sub(seed, 1298), x * WARP_SCALE, y * WARP_SCALE) - 0.5);
73
- return Math.abs(fbm(sub(seed, 8654), wx * RIVER_SCALE, wy * RIVER_SCALE) - 0.5) < RIVER_WIDTH;
74
- }
75
-
76
  // ../auto-battler/src/render/chunkedMap.js
77
  function createChunkedMap(pixi, host, config) {
78
  const { Application, Assets, Sprite, Container, Texture, Rectangle, RenderTexture } = pixi;
79
- const TILE3 = config.tile ?? 8;
80
- const CHUNK2 = config.chunk ?? 32;
81
- const CHUNKPX = CHUNK2 * TILE3;
82
  const BG = config.background ?? "#69636f";
83
  const Z_DEFAULT = config.zoomDefault ?? 1;
84
  const Z_MIN = config.zoomMin ?? 1 / 32;
@@ -91,6 +16,16 @@ function createChunkedMap(pixi, host, config) {
91
  const MACRO_LEVEL_MAX = config.macroLevelMax ?? 6;
92
  const DETAIL_BUDGET = config.detailBudget ?? 1;
93
  const MACRO_BUDGET = config.macroBudget ?? 8;
 
 
 
 
 
 
 
 
 
 
94
  let app = null, root = null, genRoot = null, macroRoot = null, shadowLayer = null, propLayer = null;
95
  let ctx = null;
96
  let alive = true;
@@ -98,7 +33,7 @@ function createChunkedMap(pixi, host, config) {
98
  let seed = config.seed ?? 1;
99
  let zoom = Z_DEFAULT;
100
  let cameraDirty = true, genPending = false, lastEmitTile = null;
101
- const camera = { x: CHUNKPX / 2, y: CHUNKPX / 2 };
102
  const chunks = /* @__PURE__ */ new Map();
103
  const macroChunks = /* @__PURE__ */ new Map();
104
  const fading = /* @__PURE__ */ new Set();
@@ -111,12 +46,12 @@ function createChunkedMap(pixi, host, config) {
111
  const pinch = { on: false, dist: 0 };
112
  const handlers = {};
113
  const emit = () => listeners.forEach((fn) => fn(getSnapshot()));
114
- const getSnapshot = () => ({ seed, zoom, cx: Math.round(camera.x / TILE3), cy: Math.round(camera.y / TILE3), chunks: chunks.size });
115
  function tex(source, c, r) {
116
  const k = source.uid + ":" + c + "," + r;
117
  let t = texCache.get(k);
118
  if (!t) {
119
- t = new Texture({ source, frame: new Rectangle(c * TILE3, r * TILE3, TILE3, TILE3) });
120
  texCache.set(k, t);
121
  }
122
  return t;
@@ -131,16 +66,16 @@ function createChunkedMap(pixi, host, config) {
131
  return t;
132
  }
133
  function makeChunk(cx, cy) {
134
- const x0 = cx * CHUNK2, y0 = cy * CHUNK2;
135
  const tmp = new Container();
136
  const add = (source, c, r, tx, ty) => {
137
  const sp = new Sprite(tex(source, c, r));
138
- sp.x = tx * TILE3;
139
- sp.y = ty * TILE3;
140
  tmp.addChild(sp);
141
  return sp;
142
  };
143
- const res = config.bake({ cx, cy, x0, y0, seed, chunk: CHUNK2, tile: TILE3, ctx, tmp, app, Sprite, Texture, Rectangle, tex, texFrame, add }) || {};
144
  const rt = RenderTexture.create({ width: CHUNKPX, height: CHUNKPX, autoGenerateMipmaps: true, scaleMode: "nearest" });
145
  rt.source.minFilter = "linear";
146
  rt.source.mipmapFilter = "linear";
@@ -148,12 +83,12 @@ function createChunkedMap(pixi, host, config) {
148
  rt.source.updateMipmaps();
149
  tmp.destroy({ children: true });
150
  const sprite = new Sprite(rt);
151
- sprite.x = x0 * TILE3;
152
- sprite.y = y0 * TILE3;
153
  return { sprite, rt, meta: res.meta ?? null, live: res.live ?? null };
154
  }
155
  function chooseMacroLevel(z) {
156
- return Math.max(0, Math.min(MACRO_LEVEL_MAX, Math.round(Math.log2(MACRO_TEXEL_PX / (TILE3 * z)))));
157
  }
158
  function makeMacroChunk(L, mcx, mcy) {
159
  const step2 = 1 << L, t0x = mcx * MCHUNK * step2, t0y = mcy * MCHUNK * step2;
@@ -174,13 +109,26 @@ function createChunkedMap(pixi, host, config) {
174
  const t = Texture.from(cv);
175
  t.source.scaleMode = "linear";
176
  const sprite = new Sprite(t);
177
- sprite.x = t0x * TILE3;
178
- sprite.y = t0y * TILE3;
179
- sprite.width = sprite.height = MCHUNK * step2 * TILE3;
180
  return { sprite, tex: t };
181
  }
 
 
 
 
 
 
 
 
 
 
 
182
  function reconcile() {
183
  if (!genRoot || !ctx || !app) return;
 
 
184
  const sx = Math.round(app.screen.width / 2 - camera.x * zoom);
185
  const sy = Math.round(app.screen.height / 2 - camera.y * zoom);
186
  for (const L of [macroRoot, genRoot, shadowLayer, propLayer]) {
@@ -217,8 +165,14 @@ function createChunkedMap(pixi, host, config) {
217
  }
218
  function reconcileDetail() {
219
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
220
- const c0 = Math.floor((camera.x - halfW) / CHUNKPX) - 1, c1 = Math.floor((camera.x + halfW) / CHUNKPX) + 1;
221
- const r0 = Math.floor((camera.y - halfH) / CHUNKPX) - 1, r1 = Math.floor((camera.y + halfH) / CHUNKPX) + 1;
 
 
 
 
 
 
222
  for (const [key, ch] of chunks) {
223
  const [cx, cy] = key.split(",").map(Number);
224
  if (cx < c0 - 1 || cx > c1 + 1 || cy < r0 - 1 || cy > r1 + 1) evictChunk(key, ch);
@@ -244,10 +198,17 @@ function createChunkedMap(pixi, host, config) {
244
  return missing.length > DETAIL_BUDGET;
245
  }
246
  function reconcileMacro(L) {
247
- const px = MCHUNK * (1 << L) * TILE3;
248
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
249
- const c0 = Math.floor((camera.x - halfW) / px) - 1, c1 = Math.floor((camera.x + halfW) / px) + 1;
250
- const r0 = Math.floor((camera.y - halfH) / px) - 1, r1 = Math.floor((camera.y + halfH) / px) + 1;
 
 
 
 
 
 
 
251
  for (const [key, mc] of macroChunks) {
252
  const [ml, mx, my] = key.split(",").map(Number);
253
  if (ml !== L || mx < c0 - 1 || mx > c1 + 1 || my < r0 - 1 || my > r1 + 1) {
@@ -284,7 +245,7 @@ function createChunkedMap(pixi, host, config) {
284
  }
285
  function zoomAt(factor, lx, ly) {
286
  if (!app) return;
287
- const nz = Math.min(Z_MAX, Math.max(Z_MIN, zoom * factor));
288
  if (nz === zoom) return;
289
  const sw = app.screen.width, sh = app.screen.height;
290
  const wx = camera.x + (lx - sw / 2) / zoom, wy = camera.y + (ly - sh / 2) / zoom;
@@ -409,7 +370,7 @@ function createChunkedMap(pixi, host, config) {
409
  if (cameraDirty || genPending) {
410
  reconcile();
411
  cameraDirty = false;
412
- const tk = Math.round(camera.x / TILE3) + "," + Math.round(camera.y / TILE3) + "," + zoom.toFixed(3);
413
  if (tk !== lastEmitTile) {
414
  lastEmitTile = tk;
415
  emit();
@@ -470,11 +431,17 @@ function createChunkedMap(pixi, host, config) {
470
  }
471
  function tileIndexAt(wx, wy) {
472
  if (!config.tileIndexAt) return null;
473
- const cx = Math.floor(wx / CHUNK2), cy = Math.floor(wy / CHUNK2);
474
  const ch = chunks.get(cx + "," + cy);
475
  if (!ch) return null;
476
  return config.tileIndexAt(wx, wy, ch.meta);
477
  }
 
 
 
 
 
 
478
  function setEnabled(v) {
479
  enabled = v;
480
  if (v) cameraDirty = true;
@@ -523,7 +490,207 @@ function createChunkedMap(pixi, host, config) {
523
  ctx = null;
524
  texCache.clear();
525
  }
526
- return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE3 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  }
528
 
529
  // ../auto-battler/src/render/tileAutotile.js
@@ -532,6 +699,7 @@ var rect = (c, r, w, h) => {
532
  for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) a.push([c + i, r + j]);
533
  return a;
534
  };
 
535
  var rhash = (x, y, salt, seed) => Math.imul(x * 73856093 ^ y * 19349663 ^ seed + (salt | 0), 2654435761) >>> 0;
536
  function hashU32(a, b, c) {
537
  let h = Math.imul((a | 0) ^ 2654435769, 2654435761);
@@ -546,6 +714,45 @@ var sparse = (base, vars, x, y, salt, rate, seed) => {
546
  return h % rate === 0 ? vars[(h >>> 5) % vars.length] : base;
547
  };
548
  var lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  function blockTile([c0, r0], N, E, S, W) {
550
  const cx = W && !E ? 0 : E && !W ? 2 : 1;
551
  const cy = N && !S ? 0 : S && !N ? 2 : 1;
@@ -643,21 +850,215 @@ function bakeCliffs(cfg, { chunk, x0, y0, seed, raised, place, accept = () => tr
643
  }
644
  }
645
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  // ../auto-battler/src/render/forgottenPlains.js
647
  var FP = "/assets/minifantasy/Minifantasy_ForgottenPlains_v3.6_Commercial_Version/Minifantasy_ForgottenPlains_Assets";
648
- var TILES = `${FP}/Tileset/Minifantasy_ForgottenPlainsTiles.png`;
649
  var SHADOW = `${FP}/Tileset/Minifantasy_ForgottenPlainsTilesShadows.png`;
650
  var PROPS = `${FP}/props/Minifantasy_ForgottenPlainsProps.png`;
651
  var PROP_SHADOW = `${FP}/props/Minifantasy_ForgottenPlainsPropsShadows.png`;
652
  var FP_MOCKUP_URL = `${FP}/Minifantasy_ForgottenPlainsMockup.png`;
653
- var TILE = 8;
654
- var CHUNK = 32;
655
  var GRASS_BASE = [37, 11];
656
  var GRASS_VARS = [...rect(1, 1, 4, 1), ...rect(2, 3, 3, 5)];
657
  var GRASS_RATE = 9;
658
  var DIRT_BLOCK = [7, 3];
659
  var DIRT_FILL = [8, 4];
660
- var DIRT_VARS = [[7, 1], [8, 1], [9, 1]];
661
  var STONE_BLOCK = [12, 3];
662
  var STONE_FILL = [13, 4];
663
  var STONE_VARS = [[12, 1], [13, 1], [14, 1]];
@@ -795,8 +1196,8 @@ var STONE_BLOCK_Y = 2;
795
  var STONE_CLUMP_THRESHOLD = 0.7;
796
  var STONE_CLUMP_MIN_NB = 3;
797
  var STONE_CLUMP_FILL = 0.7;
798
- var COL_GRASS = [97, 150, 55];
799
- var COL_DIRT = [118, 80, 38];
800
  var COL_STONE = [120, 120, 122];
801
  var COL_WATER = [74, 116, 196];
802
  function loadImg(url) {
@@ -814,9 +1215,9 @@ async function buildShadowMask(url) {
814
  cv.height = img.naturalHeight;
815
  const g = cv.getContext("2d", { willReadFrequently: true });
816
  g.drawImage(img, 0, 0);
817
- const cols = img.naturalWidth / TILE | 0, rows = img.naturalHeight / TILE | 0, set = /* @__PURE__ */ new Set();
818
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
819
- const d = g.getImageData(c * TILE, r * TILE, TILE, TILE).data;
820
  for (let i = 3; i < d.length; i += 4) {
821
  if (d[i] > 8) {
822
  set.add(c + "," + r);
@@ -826,9 +1227,9 @@ async function buildShadowMask(url) {
826
  }
827
  return set;
828
  }
829
- function chunkFields(seed, x0, y0) {
830
  const WATER_BUFFER = 2;
831
- const M = WATER_BUFFER + 4, SZ = CHUNK + 2 * M;
832
  const idx = (i, j) => j * SZ + i;
833
  const clean = (pred) => {
834
  let cur = new Uint8Array(SZ * SZ);
@@ -886,14 +1287,24 @@ function chunkFields(seed, x0, y0) {
886
  }
887
  return { waterF, dirt, stone, raisedField, forestRegion, stoneClumpRegion, M, SZ, x0, y0 };
888
  }
 
 
 
 
 
 
 
 
 
 
889
  var fpConfig = (seed) => ({
890
  seed,
891
- tile: TILE,
892
- chunk: CHUNK,
893
  background: "#5a7b3a",
894
  async load({ Assets }) {
895
  const ctx = { tiles: null, shadow: null, shadowSet: null, props: null, propShadow: null };
896
- const t = await Assets.load(TILES);
897
  t.source.scaleMode = "nearest";
898
  ctx.tiles = t.source;
899
  try {
@@ -924,7 +1335,7 @@ var fpConfig = (seed) => ({
924
  // `accept(tx,ty)` (chunk-local) gates which tiles this biome paints — defaults to all, so the
925
  // standalone map is unchanged; the multi-biome overworld passes a per-biome dither mask.
926
  bake({ x0, y0, seed: seed2, ctx, tmp, Sprite, tex, texFrame, add, accept = () => true }) {
927
- const f = chunkFields(seed2, x0, y0);
928
  const { waterF, dirt, stone, raisedField, forestRegion, stoneClumpRegion, M, SZ } = f;
929
  const idx = (i, j) => j * SZ + i;
930
  const atW = (wx, wy) => waterF[idx(wx - x0 + M, wy - y0 + M)] === 1;
@@ -935,8 +1346,8 @@ var fpConfig = (seed) => ({
935
  add(ctx.tiles, coord[0], coord[1], tx, ty);
936
  if (ctx.shadow && (!ctx.shadowSet || ctx.shadowSet.has(coord[0] + "," + coord[1]))) {
937
  const sh = new Sprite(tex(ctx.shadow, coord[0], coord[1]));
938
- sh.x = tx * TILE;
939
- sh.y = ty * TILE;
940
  tmp.addChild(sh);
941
  }
942
  };
@@ -957,24 +1368,24 @@ var fpConfig = (seed) => ({
957
  } else cr = blockTile(BLOCK, N, E, S, W);
958
  place(cr, tx, ty);
959
  };
960
- for (let ty = 0; ty < CHUNK; ty++) for (let tx = 0; tx < CHUNK; tx++) {
961
  if (!accept(tx, ty)) continue;
962
  const wx = x0 + tx, wy = y0 + ty;
963
  place(sparse(GRASS_BASE, GRASS_VARS, wx, wy, 0, GRASS_RATE, seed2), tx, ty);
964
- if (atD(wx, wy)) blob(atD, DIRT_BLOCK, DIRT_FILL, DIRT_VARS, 1, wx, wy, tx, ty);
965
  if (atS(wx, wy)) blob(atS, STONE_BLOCK, STONE_FILL, STONE_VARS, 2, wx, wy, tx, ty);
966
  if (atW(wx, wy)) blob(atW, WATER_BLOCK, WATER_FILL, WATER_VARS, 3, wx, wy, tx, ty);
967
  }
968
  const live = [];
969
  const propSprite = (spec, wx, wy) => {
970
- const sp = new Sprite(texFrame(ctx.props, spec.c * TILE, (spec.r - spec.h + 1) * TILE, spec.w * TILE, spec.h * TILE));
971
  sp.anchor.set(0.5, 1);
972
- sp.x = wx * TILE + TILE / 2;
973
- sp.y = (wy + 1) * TILE;
974
- sp.zIndex = (wy + 1) * TILE;
975
  return sp;
976
  };
977
- if (ctx.props) for (let ty = 0; ty < CHUNK; ty++) for (let tx = 0; tx < CHUNK; tx++) {
978
  if (!accept(tx, ty)) continue;
979
  const wx = x0 + tx, wy = y0 + ty;
980
  if (atW(wx, wy)) {
@@ -997,9 +1408,9 @@ var fpConfig = (seed) => ({
997
  continue;
998
  }
999
  const [c, r] = g.tiles[(h >>> 16) % g.tiles.length];
1000
- const sp = new Sprite(texFrame(ctx.props, c * TILE, r * TILE, TILE, TILE));
1001
- sp.x = tx * TILE;
1002
- sp.y = ty * TILE;
1003
  tmp.addChild(sp);
1004
  }
1005
  const stairAt = (wx, wy) => {
@@ -1010,15 +1421,15 @@ var fpConfig = (seed) => ({
1010
  const off = hashU32(seed2 ^ 358940, Math.floor(wx / STAIR_SPACING), 0) % STAIR_SPACING;
1011
  return ((wy - off) % STAIR_SPACING + STAIR_SPACING) % STAIR_SPACING === 0;
1012
  };
1013
- bakeCliffs(FP_CLIFF, { chunk: CHUNK, x0, y0, seed: seed2, raised: isRaisedAt, place, accept, stairAt, stairAtV });
1014
  if (ctx.props) {
1015
  const inGrove = (wx, wy) => forestRegion[idx(wx - x0 + M, wy - y0 + M)] === 1;
1016
- const bx0 = Math.floor(x0 / FOREST_BLOCK_X), bx1 = Math.floor((x0 + CHUNK - 1) / FOREST_BLOCK_X);
1017
- const by0 = Math.floor(y0 / FOREST_BLOCK_Y), by1 = Math.floor((y0 + CHUNK - 1) / FOREST_BLOCK_Y);
1018
  for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
1019
  const h = hashU32(seed2 ^ 15748695, bx, by);
1020
  const wx = bx * FOREST_BLOCK_X + h % FOREST_BLOCK_X, wy = by * FOREST_BLOCK_Y + (h >>> 4) % FOREST_BLOCK_Y;
1021
- if (wx < x0 || wx >= x0 + CHUNK || wy < y0 || wy >= y0 + CHUNK) continue;
1022
  if (!accept(wx - x0, wy - y0)) continue;
1023
  if (!inGrove(wx, wy)) continue;
1024
  if ((h >>> 8 & 65535) / 65536 >= FOREST_FILL) continue;
@@ -1034,8 +1445,8 @@ var fpConfig = (seed) => ({
1034
  const T = TREES[(h >>> 24) % TREES.length];
1035
  const flip = (h >>> 20 & 255) / 256 < TREE_FLIP_RATE;
1036
  const sc = TREE_SCALE_BASE * (1 + ((h >>> 12 & 255) / 256 - 0.5) * 2 * TREE_SCALE_JITTER);
1037
- const px = wx * TILE + TILE / 2 + ((h >>> 16 & 15) / 16 - 0.5) * 2 * TREE_JITTER;
1038
- const py = wy * TILE + TILE / 2 + ((h >>> 28 & 15) / 16 - 0.5) * 2 * TREE_JITTER;
1039
  const tr = new Sprite(texFrame(ctx.props, ...T.frame));
1040
  tr.anchor.set(T.ax, T.ay);
1041
  tr.scale.set(flip ? -sc : sc, sc);
@@ -1055,12 +1466,12 @@ var fpConfig = (seed) => ({
1055
  }
1056
  if (ctx.props) {
1057
  const inClump = (wx, wy) => stoneClumpRegion[idx(wx - x0 + M, wy - y0 + M)] === 1;
1058
- const sbx0 = Math.floor(x0 / STONE_BLOCK_X), sbx1 = Math.floor((x0 + CHUNK - 1) / STONE_BLOCK_X);
1059
- const sby0 = Math.floor(y0 / STONE_BLOCK_Y), sby1 = Math.floor((y0 + CHUNK - 1) / STONE_BLOCK_Y);
1060
  for (let by = sby0; by <= sby1; by++) for (let bx = sbx0; bx <= sbx1; bx++) {
1061
  const h = hashU32(seed2 ^ 5702885, bx, by);
1062
  const wx = bx * STONE_BLOCK_X + h % STONE_BLOCK_X, wy = by * STONE_BLOCK_Y + (h >>> 4) % STONE_BLOCK_Y;
1063
- if (wx < x0 || wx >= x0 + CHUNK || wy < y0 || wy >= y0 + CHUNK) continue;
1064
  if (!accept(wx - x0, wy - y0)) continue;
1065
  if (!inClump(wx, wy)) continue;
1066
  if ((h >>> 8 & 65535) / 65536 >= STONE_CLUMP_FILL) continue;
@@ -1089,14 +1500,14 @@ var fpConfig = (seed) => ({
1089
  macroColor(seed2, tx, ty) {
1090
  const raised = isRaised(seed2, tx, ty);
1091
  if (!raised && isRiver(seed2, tx, ty)) return COL_WATER;
1092
- let c = isStone(seed2, tx, ty) && !isDirt(seed2, tx, ty) ? COL_STONE : isDirt(seed2, tx, ty) ? COL_DIRT : COL_GRASS;
1093
  if (raised) c = lerp3(c, COL_CLIFF, 0.25);
1094
  return c;
1095
  },
1096
  tileIndexAt(wx, wy, meta) {
1097
  if (!meta) return null;
1098
  const { waterF, dirt, stone, M, SZ, x0, y0 } = meta;
1099
- if (wx - x0 < 0 || wy - y0 < 0 || wx - x0 >= CHUNK || wy - y0 >= CHUNK) return null;
1100
  const idx = (i, j) => j * SZ + i;
1101
  const pick = (fld, BLOCK, fill) => {
1102
  const at = (x, y) => fld[idx(x - x0 + M, y - y0 + M)] === 1;
@@ -1115,8 +1526,752 @@ var fpConfig = (seed) => ({
1115
  return GRASS_BASE;
1116
  }
1117
  });
1118
- function createForgottenPlainsMap(pixi, host, opts = {}) {
1119
- return createChunkedMap(pixi, host, { ...fpConfig(opts.seed ?? 1), keyboardPan: opts.keyboardPan });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1120
  }
1121
 
1122
  // ../auto-battler/src/engine/skills.js
@@ -2061,10 +3216,10 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2061
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
2062
  const floats = [];
2063
  const sheetsById = {};
2064
- for (const [id, def] of Object.entries(defsById)) {
2065
  if (!def?.idle) {
2066
  sheetsById[id] = null;
2067
- continue;
2068
  }
2069
  try {
2070
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
@@ -2090,6 +3245,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2090
  sheetsById[id] = null;
2091
  }
2092
  }
 
2093
  const skillIcons = {};
2094
  for (const s of CB_SKILLS) {
2095
  try {
@@ -2125,7 +3281,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2125
  } catch {
2126
  }
2127
  const skillPlay = {};
2128
- for (const [id, def] of Object.entries(defsById)) {
2129
  const cell = sheetsById[id]?.cell;
2130
  const map = {};
2131
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
@@ -2150,8 +3306,9 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2150
  }
2151
  skillPlay[id] = map;
2152
  }
 
2153
  const view = {};
2154
- for (const [id, def] of Object.entries(defsById)) {
2155
  const sh = sheetsById[id];
2156
  const c = new Container();
2157
  let sp;
@@ -2200,6 +3357,25 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2200
  units.addChild(c);
2201
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
2202
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2203
  function setLoop(v, mode, facing) {
2204
  if (v.mode === mode && v.facing === facing) return;
2205
  v.mode = mode;
@@ -2575,6 +3751,8 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2575
  floats,
2576
  actorOf,
2577
  skillPlay,
 
 
2578
  setLoop,
2579
  playOnce,
2580
  resume,
@@ -2709,11 +3887,21 @@ function makeActor(unit, team, id, slot) {
2709
  // optional: idle until a foe is within this distance (else always engage)
2710
  };
2711
  }
2712
- function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null } = {}) {
2713
  const actors = [];
2714
  players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
2715
  enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
2716
- return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {}, world };
 
 
 
 
 
 
 
 
 
 
2717
  }
2718
  function setInput(b, id, cmd) {
2719
  if (!b.input) b.input = {};
@@ -2728,12 +3916,12 @@ var CONTACT_SLOP = 2;
2728
  var MAX_BATTLE_T = 90;
2729
  var COLLISION_Y_WEIGHT = 3.2;
2730
  var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS;
2731
- var edgeGap = (a, t) => dist(a, t) - (a.radius || 0) - (t.radius || 0);
2732
  var MELEE_REACH = 2;
2733
  var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH;
2734
  var SPELL_RANGE = 900;
2735
  var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]);
2736
- var dist = (a, e) => Math.hypot(a.x - e.x, a.y - e.y);
2737
  var hasCond = (a, type) => a.conds.some((c) => c.type === type);
2738
  var isKd = (b, a) => a.kd > b.t;
2739
  var gainAdr = (a, n) => {
@@ -2744,7 +3932,7 @@ var alliesOf = (b, a) => b.actors.filter((x) => x.alive && x.team === a.team);
2744
  function nearestFoe(b, a) {
2745
  let best = null, bd = Infinity;
2746
  for (const x of livingFoes(b, a)) {
2747
- const d = dist(a, x);
2748
  if (d < bd) {
2749
  bd = d;
2750
  best = x;
@@ -2764,7 +3952,7 @@ function mostWoundedAlly(b, a, includeSelf = true) {
2764
  }
2765
  return best;
2766
  }
2767
- var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist(x, tgt) <= ADJACENT_GW);
2768
  var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m });
2769
  var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t);
2770
  var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t);
@@ -3006,12 +4194,13 @@ function applyEffect(b, src, tgt, e, delivery = "spell", s = null) {
3006
  }
3007
  }
3008
  function shadowStep(b, a, tgt) {
 
3009
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
3010
- a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
3011
- a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
3012
  }
3013
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
3014
- const base = dist(src, tgt) / 900;
3015
  for (let i = 0; i < n; i++) {
3016
  b.projectiles.push({
3017
  srcId: src.id,
@@ -3088,7 +4277,7 @@ function strike(b, a, enemy, s) {
3088
  }
3089
  }
3090
  if (a.role === "ranged") {
3091
- const flight = dist(a, enemy) / (a.weapon.projSpeed || 800);
3092
  b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect });
3093
  log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id });
3094
  } else {
@@ -3201,7 +4390,7 @@ function usable(b, a, s, tgt, foe, free = false) {
3201
  if (s.cost?.energy && a.energy < s.cost.energy) return false;
3202
  if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false;
3203
  if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false;
3204
- if (!isAttack(s) && !isSupport(s) && dist(a, foe) > SPELL_RANGE) return false;
3205
  for (const r of s.requires || []) {
3206
  if (r === "on_hit") continue;
3207
  if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false;
@@ -3235,7 +4424,7 @@ function chooseAction(b, a, foe) {
3235
  return null;
3236
  }
3237
  function moveActor(b, a, enemy, dt) {
3238
- const d = dist(a, enemy);
3239
  let toward = 0;
3240
  if (a.role === "ranged") {
3241
  if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1;
@@ -3309,9 +4498,10 @@ function timeToHit(px, py, rvx, rvy, R) {
3309
  }
3310
  var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
3311
  function stepMove(b, a, vx, vy, dt) {
 
3312
  const w = b.world && b.world.walkable;
3313
- let nx = clampField(a.x + vx * dt, a.radius, FIELD.w);
3314
- let ny = clampField(a.y + vy * dt, a.radius, FIELD.h);
3315
  if (w) {
3316
  if (!w(nx, a.y)) nx = a.x;
3317
  if (!w(nx, ny)) ny = a.y;
@@ -3320,6 +4510,7 @@ function stepMove(b, a, vx, vy, dt) {
3320
  a.y = ny;
3321
  }
3322
  function resolveOverlaps(b) {
 
3323
  const live = b.actors.filter((a) => a.alive);
3324
  for (let it = 0; it < DEOVERLAP_ITERS; it++) {
3325
  for (let i = 0; i < live.length; i++) {
@@ -3335,10 +4526,10 @@ function resolveOverlaps(b) {
3335
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
3336
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
3337
  const yPush = uy / COLLISION_Y_WEIGHT;
3338
- a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w);
3339
- a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h);
3340
- o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w);
3341
- o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h);
3342
  }
3343
  }
3344
  }
@@ -3439,7 +4630,7 @@ function step(b, dt) {
3439
  if (!a.alive || b.over) continue;
3440
  const enemy = nearestFoe(b, a);
3441
  if (!enemy && a.control !== "player") continue;
3442
- if (a.aggroRadius != null && a.control !== "player" && dist(a, enemy) > a.aggroRadius) {
3443
  a.moving = false;
3444
  continue;
3445
  }
@@ -3498,14 +4689,26 @@ function step(b, dt) {
3498
  }
3499
 
3500
  // ../auto-battler/src/sim/walkable.js
3501
- function makeWalkable(seed) {
3502
- return (wx, wy) => !isRiver(seed, wx, wy) && !isRaised(seed, wx, wy);
 
 
 
 
 
3503
  }
3504
 
3505
  // ../auto-battler/src/render/comboBattler.js
3506
- var TILE2 = 8;
3507
  var SPRITE_TILES = 1.9;
3508
  var STEP = 0.05;
 
 
 
 
 
 
 
3509
  async function contentHeight(url) {
3510
  try {
3511
  const img = await new Promise((res, rej) => {
@@ -3535,10 +4738,20 @@ function mountComboBattler(pixi, host, opts = {}) {
3535
  const { Graphics, Container } = pixi;
3536
  const seed = (opts.seed ?? 1) >>> 0;
3537
  const G = opts.groundScale ?? 0.3;
3538
- const mw = makeWalkable(seed);
3539
- const world = { walkable: (fx, fy) => mw(Math.round(fx * G / TILE2), Math.round(fy * G / TILE2)) };
3540
- const map = createForgottenPlainsMap(pixi, host, { seed, keyboardPan: false });
3541
- let battle = null, R = null, combatRoot = null, rings = null;
 
 
 
 
 
 
 
 
 
 
3542
  const depthScale = { v: 2 };
3543
  let offTick = null, keyHandlers = null, tapHandlers = null, controlsEl = null, alive = true;
3544
  const keys = { x: 0, y: 0 };
@@ -3549,10 +4762,14 @@ function mountComboBattler(pixi, host, opts = {}) {
3549
  const listeners = /* @__PURE__ */ new Set();
3550
  const pa = () => battle?.actors.find((a) => a.id === "P0") || null;
3551
  const ea = () => battle?.actors.filter((a) => a.team === "enemy") || [];
 
 
 
 
3552
  const snap = () => ({
3553
- player: pa() ? { x: pa().x, y: pa().y, hp: pa().hp, wx: Math.round(pa().x * G / TILE2), wy: Math.round(pa().y * G / TILE2) } : null,
3554
- enemies: ea().map((a) => ({ x: a.x, y: a.y, hp: Math.round(a.hp), alive: a.alive, wx: Math.round(a.x * G / TILE2), wy: Math.round(a.y * G / TILE2) })),
3555
- over: !!battle?.over
3556
  });
3557
  const emit = () => listeners.forEach((fn) => fn(snap()));
3558
  const KEYMAP = { w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], arrowup: [0, -1], arrowdown: [0, 1], arrowleft: [-1, 0], arrowright: [1, 0] };
@@ -3613,7 +4830,7 @@ function mountComboBattler(pixi, host, opts = {}) {
3613
  downAt = null;
3614
  if (moved > 8 || elapsed > 500) return;
3615
  const r = canvas.getBoundingClientRect(), w = map.screenToWorld(e.clientX - r.left, e.clientY - r.top);
3616
- moveTarget = { x: w.x / G, y: w.y / G };
3617
  };
3618
  canvas.addEventListener("pointerdown", onDown);
3619
  window.addEventListener("pointerup", onUp);
@@ -3646,12 +4863,12 @@ function mountComboBattler(pixi, host, opts = {}) {
3646
  host.appendChild(bar);
3647
  controlsEl = bar;
3648
  }
3649
- const dist2 = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
3650
  const nearestFoeDist = () => {
3651
  const p = pa();
3652
  if (!p) return Infinity;
3653
  let m = Infinity;
3654
- for (const e of ea()) if (e.alive) m = Math.min(m, dist2(p, e));
3655
  return m;
3656
  };
3657
  function tick(ticker) {
@@ -3682,11 +4899,16 @@ function mountComboBattler(pixi, host, opts = {}) {
3682
  step(battle, STEP);
3683
  acc.t -= STEP;
3684
  }
 
 
 
 
3685
  R.syncActors(battle, dtMS, battle.t);
3686
  R.updateFloats(dtMS);
3687
  R.drawProjectiles(battle);
3688
  R.processLog(battle, cursor);
3689
  drawRings();
 
3690
  emit();
3691
  }
3692
  function drawRings() {
@@ -3695,42 +4917,53 @@ function mountComboBattler(pixi, host, opts = {}) {
3695
  if (!p) return;
3696
  rings.ellipse(p.x, p.y, p.weapon.range, p.weapon.range * 0.6).stroke({ width: 1.5 / G, color: 16777215, alpha: 0.25 });
3697
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
3698
  const ready = (async () => {
3699
  await map.ready;
3700
  if (!alive) return;
3701
  const cam = map.getCamera();
3702
- const snapTile = (wx, wy) => {
3703
- for (let r = 0; r <= 20; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) if (Math.max(Math.abs(dx), Math.abs(dy)) === r && mw(wx + dx, wy + dy)) return { x: wx + dx, y: wy + dy };
3704
- return { x: wx, y: wy };
 
 
 
 
 
 
 
 
 
3705
  };
3706
- const p0 = snapTile(Math.floor(cam.x / TILE2), Math.floor(cam.y / TILE2));
3707
- const toField = (tile) => ({ x: (tile.x + 0.5) * TILE2 / G, y: (tile.y + 1) * TILE2 / G });
3708
  const pProf = opts.player?.unit?.profession;
3709
  const pSkills = opts.player?.unit?.skills?.length ? opts.player.unit.skills : CB_SKILLS.filter((s) => s.profession === pProf).slice(0, 3).map((s) => s.id);
3710
  const players = [{ ...opts.player?.unit || {}, name: opts.player?.name || "Hero", control: "player", skills: pSkills }];
3711
- const enemyUnits = (opts.enemies || []).map((e) => ({ ...e.unit || {}, name: e.name }));
3712
- battle = makeTeamBattle({ seed, players, enemies: enemyUnits, sandbox: true, freeCast: true, world });
3713
- const pf = toField(p0);
3714
  const P = pa();
3715
  if (P) {
3716
- P.x = pf.x;
3717
- P.y = pf.y;
3718
- }
3719
- ea().forEach((a, i) => {
3720
- const ring = 7 + i % 3 * 3, ang = i / Math.max(1, ea().length) * Math.PI * 2;
3721
- const t = snapTile(p0.x + Math.round(Math.cos(ang) * ring), p0.y + Math.round(Math.sin(ang) * ring));
3722
- const f = toField(t);
3723
- a.x = f.x;
3724
- a.y = f.y;
3725
- });
3726
  const defsById = {};
3727
  const sd = (o) => ({ idle: o?.idle, walk: o?.walk, attack: o?.attack, dmg: o?.dmg, die: o?.die });
3728
  defsById.P0 = { name: players[0].name, profession: players[0].profession, ...sd(opts.player?.sheets) };
3729
- ea().forEach((a, i) => {
3730
- defsById[a.id] = { name: a.name, ...sd((opts.enemies || [])[i]?.sheets) };
3731
- });
3732
  combatRoot = new Container();
3733
  combatRoot.scale.set(G);
 
3734
  rings = new Graphics();
3735
  combatRoot.addChild(rings);
3736
  const units = new Container();
@@ -3739,11 +4972,59 @@ function mountComboBattler(pixi, host, opts = {}) {
3739
  const proj = new Graphics();
3740
  combatRoot.addChild(units, proj, fx);
3741
  map.getEntityLayer().addChild(combatRoot);
 
 
3742
  const ch = await contentHeight(opts.player?.sheets?.idle || opts.player?.sheets?.walk);
3743
- depthScale.v = SPRITE_TILES * TILE2 / (ch * G);
3744
  R = await createCombatRenderer({ pixi, defsById, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
3745
  if (!alive) return;
3746
  for (const a of battle.actors) a.attackTimer = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3747
  bindKeys();
3748
  bindTap();
3749
  buildControls();
@@ -3781,6 +5062,10 @@ function mountComboBattler(pixi, host, opts = {}) {
3781
  controlsEl?.remove();
3782
  } catch {
3783
  }
 
 
 
 
3784
  try {
3785
  map.destroy();
3786
  } catch {
@@ -3790,7 +5075,7 @@ function mountComboBattler(pixi, host, opts = {}) {
3790
  combatRoot = null;
3791
  listeners.clear();
3792
  }
3793
- const ctrl = { ready, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => mw(wx, wy) };
3794
  if (typeof window !== "undefined") {
3795
  window.__comboSnap = () => ctrl.getSnapshot();
3796
  window.__combo = ctrl;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  // ../auto-battler/src/render/chunkedMap.js
2
  function createChunkedMap(pixi, host, config) {
3
  const { Application, Assets, Sprite, Container, Texture, Rectangle, RenderTexture } = pixi;
4
+ const TILE7 = config.tile ?? 8;
5
+ const CHUNK5 = config.chunk ?? 32;
6
+ const CHUNKPX = CHUNK5 * TILE7;
7
  const BG = config.background ?? "#69636f";
8
  const Z_DEFAULT = config.zoomDefault ?? 1;
9
  const Z_MIN = config.zoomMin ?? 1 / 32;
 
16
  const MACRO_LEVEL_MAX = config.macroLevelMax ?? 6;
17
  const DETAIL_BUDGET = config.detailBudget ?? 1;
18
  const MACRO_BUDGET = config.macroBudget ?? 8;
19
+ const bounds = config.bounds ? {
20
+ x0: config.bounds.x0 * TILE7,
21
+ y0: config.bounds.y0 * TILE7,
22
+ x1: config.bounds.x1 * TILE7,
23
+ y1: config.bounds.y1 * TILE7,
24
+ tx0: config.bounds.x0,
25
+ ty0: config.bounds.y0,
26
+ tx1: config.bounds.x1,
27
+ ty1: config.bounds.y1
28
+ } : null;
29
  let app = null, root = null, genRoot = null, macroRoot = null, shadowLayer = null, propLayer = null;
30
  let ctx = null;
31
  let alive = true;
 
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
36
+ const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
37
  const chunks = /* @__PURE__ */ new Map();
38
  const macroChunks = /* @__PURE__ */ new Map();
39
  const fading = /* @__PURE__ */ new Set();
 
46
  const pinch = { on: false, dist: 0 };
47
  const handlers = {};
48
  const emit = () => listeners.forEach((fn) => fn(getSnapshot()));
49
+ const getSnapshot = () => ({ seed, zoom, cx: Math.round(camera.x / TILE7), cy: Math.round(camera.y / TILE7), chunks: chunks.size });
50
  function tex(source, c, r) {
51
  const k = source.uid + ":" + c + "," + r;
52
  let t = texCache.get(k);
53
  if (!t) {
54
+ t = new Texture({ source, frame: new Rectangle(c * TILE7, r * TILE7, TILE7, TILE7) });
55
  texCache.set(k, t);
56
  }
57
  return t;
 
66
  return t;
67
  }
68
  function makeChunk(cx, cy) {
69
+ const x0 = cx * CHUNK5, y0 = cy * CHUNK5;
70
  const tmp = new Container();
71
  const add = (source, c, r, tx, ty) => {
72
  const sp = new Sprite(tex(source, c, r));
73
+ sp.x = tx * TILE7;
74
+ sp.y = ty * TILE7;
75
  tmp.addChild(sp);
76
  return sp;
77
  };
78
+ const res = config.bake({ cx, cy, x0, y0, seed, chunk: CHUNK5, tile: TILE7, ctx, tmp, app, Sprite, Texture, Rectangle, tex, texFrame, add }) || {};
79
  const rt = RenderTexture.create({ width: CHUNKPX, height: CHUNKPX, autoGenerateMipmaps: true, scaleMode: "nearest" });
80
  rt.source.minFilter = "linear";
81
  rt.source.mipmapFilter = "linear";
 
83
  rt.source.updateMipmaps();
84
  tmp.destroy({ children: true });
85
  const sprite = new Sprite(rt);
86
+ sprite.x = x0 * TILE7;
87
+ sprite.y = y0 * TILE7;
88
  return { sprite, rt, meta: res.meta ?? null, live: res.live ?? null };
89
  }
90
  function chooseMacroLevel(z) {
91
+ return Math.max(0, Math.min(MACRO_LEVEL_MAX, Math.round(Math.log2(MACRO_TEXEL_PX / (TILE7 * z)))));
92
  }
93
  function makeMacroChunk(L, mcx, mcy) {
94
  const step2 = 1 << L, t0x = mcx * MCHUNK * step2, t0y = mcy * MCHUNK * step2;
 
109
  const t = Texture.from(cv);
110
  t.source.scaleMode = "linear";
111
  const sprite = new Sprite(t);
112
+ sprite.x = t0x * TILE7;
113
+ sprite.y = t0y * TILE7;
114
+ sprite.width = sprite.height = MCHUNK * step2 * TILE7;
115
  return { sprite, tex: t };
116
  }
117
+ function coverZoom() {
118
+ if (!bounds || !app) return Z_MIN;
119
+ return Math.max(app.screen.width / (bounds.x1 - bounds.x0), app.screen.height / (bounds.y1 - bounds.y0));
120
+ }
121
+ function clampCamera() {
122
+ if (!bounds || !app) return;
123
+ const hw = app.screen.width / 2 / zoom, hh = app.screen.height / 2 / zoom;
124
+ const loX = bounds.x0 + hw, hiX = bounds.x1 - hw, loY = bounds.y0 + hh, hiY = bounds.y1 - hh;
125
+ camera.x = loX <= hiX ? Math.min(hiX, Math.max(loX, camera.x)) : (bounds.x0 + bounds.x1) / 2;
126
+ camera.y = loY <= hiY ? Math.min(hiY, Math.max(loY, camera.y)) : (bounds.y0 + bounds.y1) / 2;
127
+ }
128
  function reconcile() {
129
  if (!genRoot || !ctx || !app) return;
130
+ if (bounds) zoom = Math.max(zoom, coverZoom());
131
+ clampCamera();
132
  const sx = Math.round(app.screen.width / 2 - camera.x * zoom);
133
  const sy = Math.round(app.screen.height / 2 - camera.y * zoom);
134
  for (const L of [macroRoot, genRoot, shadowLayer, propLayer]) {
 
165
  }
166
  function reconcileDetail() {
167
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
168
+ let c0 = Math.floor((camera.x - halfW) / CHUNKPX) - 1, c1 = Math.floor((camera.x + halfW) / CHUNKPX) + 1;
169
+ let r0 = Math.floor((camera.y - halfH) / CHUNKPX) - 1, r1 = Math.floor((camera.y + halfH) / CHUNKPX) + 1;
170
+ if (bounds) {
171
+ c0 = Math.max(c0, Math.floor(bounds.tx0 / CHUNK5));
172
+ c1 = Math.min(c1, Math.floor((bounds.tx1 - 1) / CHUNK5));
173
+ r0 = Math.max(r0, Math.floor(bounds.ty0 / CHUNK5));
174
+ r1 = Math.min(r1, Math.floor((bounds.ty1 - 1) / CHUNK5));
175
+ }
176
  for (const [key, ch] of chunks) {
177
  const [cx, cy] = key.split(",").map(Number);
178
  if (cx < c0 - 1 || cx > c1 + 1 || cy < r0 - 1 || cy > r1 + 1) evictChunk(key, ch);
 
198
  return missing.length > DETAIL_BUDGET;
199
  }
200
  function reconcileMacro(L) {
201
+ const px = MCHUNK * (1 << L) * TILE7;
202
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
203
+ let c0 = Math.floor((camera.x - halfW) / px) - 1, c1 = Math.floor((camera.x + halfW) / px) + 1;
204
+ let r0 = Math.floor((camera.y - halfH) / px) - 1, r1 = Math.floor((camera.y + halfH) / px) + 1;
205
+ if (bounds) {
206
+ const mt = MCHUNK * (1 << L);
207
+ c0 = Math.max(c0, Math.floor(bounds.tx0 / mt));
208
+ c1 = Math.min(c1, Math.floor((bounds.tx1 - 1) / mt));
209
+ r0 = Math.max(r0, Math.floor(bounds.ty0 / mt));
210
+ r1 = Math.min(r1, Math.floor((bounds.ty1 - 1) / mt));
211
+ }
212
  for (const [key, mc] of macroChunks) {
213
  const [ml, mx, my] = key.split(",").map(Number);
214
  if (ml !== L || mx < c0 - 1 || mx > c1 + 1 || my < r0 - 1 || my > r1 + 1) {
 
245
  }
246
  function zoomAt(factor, lx, ly) {
247
  if (!app) return;
248
+ const nz = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, zoom * factor));
249
  if (nz === zoom) return;
250
  const sw = app.screen.width, sh = app.screen.height;
251
  const wx = camera.x + (lx - sw / 2) / zoom, wy = camera.y + (ly - sh / 2) / zoom;
 
370
  if (cameraDirty || genPending) {
371
  reconcile();
372
  cameraDirty = false;
373
+ const tk = Math.round(camera.x / TILE7) + "," + Math.round(camera.y / TILE7) + "," + zoom.toFixed(3);
374
  if (tk !== lastEmitTile) {
375
  lastEmitTile = tk;
376
  emit();
 
431
  }
432
  function tileIndexAt(wx, wy) {
433
  if (!config.tileIndexAt) return null;
434
+ const cx = Math.floor(wx / CHUNK5), cy = Math.floor(wy / CHUNK5);
435
  const ch = chunks.get(cx + "," + cy);
436
  if (!ch) return null;
437
  return config.tileIndexAt(wx, wy, ch.meta);
438
  }
439
+ function biomeAt(wx, wy) {
440
+ return config.biomeAt ? config.biomeAt(seed, wx, wy) : null;
441
+ }
442
+ function getBounds() {
443
+ return bounds ? { x0: bounds.x0, y0: bounds.y0, x1: bounds.x1, y1: bounds.y1 } : null;
444
+ }
445
  function setEnabled(v) {
446
  enabled = v;
447
  if (v) cameraDirty = true;
 
490
  ctx = null;
491
  texCache.clear();
492
  }
493
+ return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE7 };
494
+ }
495
+
496
+ // ../auto-battler/src/engine/rng.js
497
+ function makeRng(seed) {
498
+ let a = seed >>> 0;
499
+ return function rng() {
500
+ a |= 0;
501
+ a = a + 1831565813 | 0;
502
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
503
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
504
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
505
+ };
506
+ }
507
+
508
+ // ../auto-battler/src/engine/worldgen.js
509
+ function hash2(seed, x, y) {
510
+ let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 2654435761);
511
+ h = Math.imul(h ^ h >>> 13, 1274126177);
512
+ h ^= h >>> 16;
513
+ return (h >>> 0) / 4294967296;
514
+ }
515
+ var smooth = (t) => t * t * (3 - 2 * t);
516
+ var lerp = (a, b, t) => a + (b - a) * t;
517
+ function valueNoise(seed, x, y) {
518
+ const xi = Math.floor(x), yi = Math.floor(y);
519
+ const xf = x - xi, yf = y - yi;
520
+ const v00 = hash2(seed, xi, yi), v10 = hash2(seed, xi + 1, yi);
521
+ const v01 = hash2(seed, xi, yi + 1), v11 = hash2(seed, xi + 1, yi + 1);
522
+ const u = smooth(xf), v = smooth(yf);
523
+ return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
524
+ }
525
+ function fbm(seed, x, y, octaves = 5, lacunarity = 2, gain = 0.5) {
526
+ let amp = 1, freq = 1, sum = 0, norm = 0;
527
+ for (let o = 0; o < octaves; o++) {
528
+ sum += amp * valueNoise(seed + o * 1013, x * freq, y * freq);
529
+ norm += amp;
530
+ amp *= gain;
531
+ freq *= lacunarity;
532
+ }
533
+ return sum / norm;
534
+ }
535
+
536
+ // ../auto-battler/src/engine/biomeMap.js
537
+ var sub = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
538
+ var SCALE = 5e-3;
539
+ var REGION_OCTAVES = 3;
540
+ var WARP_SCALE = 0.012;
541
+ var WARP_AMP = 40;
542
+ var BIOMES = [
543
+ { id: "forgottenPlains", salt: 985505 },
544
+ // green meadow
545
+ { id: "orc", salt: 11281 },
546
+ // dirt / grass plains
547
+ { id: "necropolis", salt: 966869 }
548
+ // corrupted swamp
549
+ ];
550
+ var OVERWORLD_BIOMES = BIOMES.map((b) => b.id);
551
+ function weights(seed, x, y, out) {
552
+ const wx = x + WARP_AMP * (fbm(sub(seed, 1441), x * WARP_SCALE, y * WARP_SCALE) - 0.5);
553
+ const wy = y + WARP_AMP * (fbm(sub(seed, 1442), x * WARP_SCALE, y * WARP_SCALE) - 0.5);
554
+ for (let i = 0; i < BIOMES.length; i++) out[i] = fbm(sub(seed, BIOMES[i].salt), wx * SCALE, wy * SCALE, REGION_OCTAVES);
555
+ return out;
556
+ }
557
+ var _w = new Array(BIOMES.length);
558
+ function biomeRegion(seed, x, y) {
559
+ const w = weights(seed, x, y, _w);
560
+ let i1 = 0;
561
+ for (let i = 1; i < w.length; i++) if (w[i] > w[i1]) i1 = i;
562
+ let i2 = -1;
563
+ for (let i = 0; i < w.length; i++) {
564
+ if (i === i1) continue;
565
+ if (i2 < 0 || w[i] > w[i2]) i2 = i;
566
+ }
567
+ return { id: BIOMES[i1].id, id2: BIOMES[i2]?.id ?? BIOMES[i1].id, edge: w[i1] - w[i2] };
568
+ }
569
+
570
+ // ../auto-battler/src/engine/transitionStencils.js
571
+ var TILE = 8;
572
+ var MID = 3.5;
573
+ var BAND = 2.6;
574
+ var WAVE_AMP = 1.35;
575
+ var WAVE_FREQ = 0.45;
576
+ var R_OUTER = 4.6;
577
+ var R_CONCAVE = 2.3;
578
+ var R_TIP = 2.6;
579
+ var BAYER4 = [
580
+ 0.5 / 16,
581
+ 8.5 / 16,
582
+ 2.5 / 16,
583
+ 10.5 / 16,
584
+ 12.5 / 16,
585
+ 4.5 / 16,
586
+ 14.5 / 16,
587
+ 6.5 / 16,
588
+ 3.5 / 16,
589
+ 11.5 / 16,
590
+ 1.5 / 16,
591
+ 9.5 / 16,
592
+ 15.5 / 16,
593
+ 7.5 / 16,
594
+ 13.5 / 16,
595
+ 5.5 / 16
596
+ ];
597
+ var bayer = (x, y) => BAYER4[(y & 3) * 4 + (x & 3)];
598
+ function wave1d(t, salt) {
599
+ const i = Math.floor(t * WAVE_FREQ), f = t * WAVE_FREQ - i;
600
+ const a = hash2(salt, i, 0), b = hash2(salt, i + 1, 0);
601
+ const u = f * f * (3 - 2 * f);
602
+ return (a + (b - a) * u) * 2 - 1;
603
+ }
604
+ var CASES = {
605
+ // cardinal edges — wavy line; fg on the side away from the foreign neighbour.
606
+ N: (x, y, s) => y - (MID + WAVE_AMP * wave1d(x, s)),
607
+ S: (x, y, s) => MID + WAVE_AMP * wave1d(x, s) - y,
608
+ E: (x, y, s) => MID + WAVE_AMP * wave1d(y, s) - x,
609
+ W: (x, y, s) => x - (MID + WAVE_AMP * wave1d(y, s)),
610
+ // outer corners — bg is a quarter-disc in the corner (2 adjacent foreign cardinals).
611
+ oNE: (x, y, s) => dist(x, y, 7, 0) - (R_OUTER + WAVE_AMP * wave1d(x + y, s)),
612
+ oNW: (x, y, s) => dist(x, y, 0, 0) - (R_OUTER + WAVE_AMP * wave1d(x + y, s)),
613
+ oSE: (x, y, s) => dist(x, y, 7, 7) - (R_OUTER + WAVE_AMP * wave1d(x + y, s)),
614
+ oSW: (x, y, s) => dist(x, y, 0, 7) - (R_OUTER + WAVE_AMP * wave1d(x + y, s)),
615
+ // concave corners — small bg notch in the corner (1 foreign diagonal only).
616
+ cNE: (x, y, s) => dist(x, y, 7, 0) - (R_CONCAVE + 0.6 * wave1d(x + y, s)),
617
+ cNW: (x, y, s) => dist(x, y, 0, 0) - (R_CONCAVE + 0.6 * wave1d(x + y, s)),
618
+ cSE: (x, y, s) => dist(x, y, 7, 7) - (R_CONCAVE + 0.6 * wave1d(x + y, s)),
619
+ cSW: (x, y, s) => dist(x, y, 0, 7) - (R_CONCAVE + 0.6 * wave1d(x + y, s)),
620
+ // peninsula — fg is a small blob on the one open side (3 foreign cardinals).
621
+ tipN: (x, y, s) => R_TIP + 0.6 * wave1d(x, s) - dist(x, y, 3.5, 0),
622
+ tipS: (x, y, s) => R_TIP + 0.6 * wave1d(x, s) - dist(x, y, 3.5, 7),
623
+ tipE: (x, y, s) => R_TIP + 0.6 * wave1d(y, s) - dist(x, y, 7, 3.5),
624
+ tipW: (x, y, s) => R_TIP + 0.6 * wave1d(y, s) - dist(x, y, 0, 3.5),
625
+ // island — fg is a small blob in the centre (foreign on all 4 cardinals).
626
+ island: (x, y, s) => R_TIP + 0.6 * wave1d(x - y, s) - dist(x, y, 3.5, 3.5)
627
+ };
628
+ function dist(x, y, cx, cy) {
629
+ const dx = x - cx, dy = y - cy;
630
+ return Math.sqrt(dx * dx + dy * dy);
631
+ }
632
+ var STENCIL_CASES = Object.keys(CASES);
633
+ function boundaryCase(N, E, S, W, NW, NE, SW, SE) {
634
+ const card = N + E + S + W;
635
+ if (card === 0) {
636
+ const diag = NW + NE + SW + SE;
637
+ if (diag === 0) return null;
638
+ if (NW) return "cNW";
639
+ if (NE) return "cNE";
640
+ if (SW) return "cSW";
641
+ return "cSE";
642
+ }
643
+ if (card === 1) return N ? "N" : E ? "E" : S ? "S" : "W";
644
+ if (card === 2) {
645
+ if (N && E) return "oNE";
646
+ if (N && W) return "oNW";
647
+ if (S && E) return "oSE";
648
+ if (S && W) return "oSW";
649
+ return N ? "N" : "E";
650
+ }
651
+ if (card === 3) return !N ? "tipN" : !E ? "tipE" : !S ? "tipS" : "tipW";
652
+ return "island";
653
+ }
654
+ function makeStencil(caseKey, variant = 0) {
655
+ const sd = CASES[caseKey];
656
+ if (!sd) throw new Error(`unknown stencil case "${caseKey}"`);
657
+ const salt = (hashStr(caseKey) ^ variant * 40503) >>> 0;
658
+ const mask = new Uint8Array(TILE * TILE);
659
+ for (let y = 0; y < TILE; y++) for (let x = 0; x < TILE; x++) {
660
+ const d = sd(x, y, salt);
661
+ let fg;
662
+ if (d > BAND / 2) fg = 1;
663
+ else if (d < -BAND / 2) fg = 0;
664
+ else fg = (d + BAND / 2) / BAND > bayer(x, y) ? 1 : 0;
665
+ mask[y * TILE + x] = fg;
666
+ }
667
+ return mask;
668
+ }
669
+ function hashStr(s) {
670
+ let h = 2166136261;
671
+ for (let i = 0; i < s.length; i++) {
672
+ h ^= s.charCodeAt(i);
673
+ h = Math.imul(h, 16777619);
674
+ }
675
+ return h >>> 0;
676
+ }
677
+
678
+ // ../auto-battler/src/engine/orcGen.js
679
+ var sub2 = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
680
+ var DDIRT_SCALE = 0.045;
681
+ var DDIRT_THRESHOLD = 0.55;
682
+ function isDarkerDirt(seed, x, y) {
683
+ return fbm(sub2(seed, 56599), x * DDIRT_SCALE, y * DDIRT_SCALE) > DDIRT_THRESHOLD;
684
+ }
685
+ var GRASS_SCALE = 0.04;
686
+ var GRASS_THRESHOLD = 0.58;
687
+ function isGrass(seed, x, y) {
688
+ return fbm(sub2(seed, 27221), x * GRASS_SCALE, y * GRASS_SCALE) > GRASS_THRESHOLD;
689
+ }
690
+ var ROCK_SCALE = 0.12;
691
+ var ROCK_THRESHOLD = 0.64;
692
+ function isRock(seed, x, y) {
693
+ return fbm(sub2(seed, 16588), x * ROCK_SCALE, y * ROCK_SCALE) > ROCK_THRESHOLD;
694
  }
695
 
696
  // ../auto-battler/src/render/tileAutotile.js
 
699
  for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) a.push([c + i, r + j]);
700
  return a;
701
  };
702
+ var offset = (t, col) => [t[0] + col, t[1]];
703
  var rhash = (x, y, salt, seed) => Math.imul(x * 73856093 ^ y * 19349663 ^ seed + (salt | 0), 2654435761) >>> 0;
704
  function hashU32(a, b, c) {
705
  let h = Math.imul((a | 0) ^ 2654435769, 2654435761);
 
714
  return h % rate === 0 ? vars[(h >>> 5) % vars.length] : base;
715
  };
716
  var lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
717
+ function autotile(set, N, E, S, W, NW, NE, SW, SE) {
718
+ const card = N + E + S + W;
719
+ if (card === 1) return N ? set.N : S ? set.S : E ? set.E : set.W;
720
+ if (card === 2) {
721
+ if (N && W) return set.vNW;
722
+ if (N && E) return set.vNE;
723
+ if (S && W) return set.vSW;
724
+ if (S && E) return set.vSE;
725
+ return N ? set.N : set.E;
726
+ }
727
+ if (card >= 3) return N ? set.N : S ? set.S : E ? set.E : set.W;
728
+ const diag = NW + NE + SW + SE;
729
+ if (diag === 0) return null;
730
+ if (diag === 1) return NW ? set.cNW : NE ? set.cNE : SW ? set.cSW : set.cSE;
731
+ if (NW && SE && !NE && !SW) return set.dNWSE;
732
+ if (NE && SW && !NW && !SE) return set.dSWNE;
733
+ return null;
734
+ }
735
+ function cleanField(raw, M, SZ) {
736
+ const idx = (i, j) => j * SZ + i;
737
+ let cur = new Uint8Array(SZ * SZ);
738
+ for (let j = 0; j < SZ; j++) for (let i = 0; i < SZ; i++) cur[idx(i, j)] = raw(i - M, j - M) ? 1 : 0;
739
+ let nxt = new Uint8Array(SZ * SZ);
740
+ for (let j = 1; j < SZ - 1; j++) for (let i = 1; i < SZ - 1; i++) {
741
+ let c = 0;
742
+ for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) c += cur[idx(i + di, j + dj)];
743
+ nxt[idx(i, j)] = c >= 5 ? 1 : 0;
744
+ }
745
+ cur = nxt;
746
+ for (let p = 0; p < 2; p++) {
747
+ nxt = new Uint8Array(SZ * SZ);
748
+ for (let j = 1; j < SZ - 1; j++) for (let i = 1; i < SZ - 1; i++) {
749
+ const card = cur[idx(i, j - 1)] + cur[idx(i + 1, j)] + cur[idx(i, j + 1)] + cur[idx(i - 1, j)];
750
+ nxt[idx(i, j)] = cur[idx(i, j)] ? card >= 2 ? 1 : 0 : card >= 3 ? 1 : 0;
751
+ }
752
+ cur = nxt;
753
+ }
754
+ return cur;
755
+ }
756
  function blockTile([c0, r0], N, E, S, W) {
757
  const cx = W && !E ? 0 : E && !W ? 2 : 1;
758
  const cy = N && !S ? 0 : S && !N ? 2 : 1;
 
850
  }
851
  }
852
 
853
+ // ../auto-battler/src/render/orcKingdom.js
854
+ var ORC = "/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets";
855
+ var TILES = `${ORC}/Tileset/Tiles.png`;
856
+ var TILE2 = 8;
857
+ var CHUNK = 32;
858
+ var DIRT_BASE = [7, 51];
859
+ var DIRT_VARS = rect(13, 51, 2, 6);
860
+ var DIRT_RATE = 4;
861
+ var GRASS = {
862
+ fill: [3, 51],
863
+ vars: [],
864
+ N: [3, 54],
865
+ S: [3, 52],
866
+ E: [2, 53],
867
+ W: [4, 53],
868
+ cNW: [4, 54],
869
+ cNE: [2, 54],
870
+ cSW: [4, 52],
871
+ cSE: [2, 52],
872
+ vNW: [3, 56],
873
+ vNE: [2, 56],
874
+ vSW: [3, 55],
875
+ vSE: [2, 55],
876
+ dNWSE: [4, 55],
877
+ dSWNE: [4, 56]
878
+ };
879
+ var DDIRT = {
880
+ fill: [7, 53],
881
+ vars: rect(10, 51, 2, 6),
882
+ N: [7, 52],
883
+ S: [7, 54],
884
+ E: [8, 53],
885
+ W: [6, 53],
886
+ vNW: [6, 52],
887
+ vNE: [8, 52],
888
+ vSW: [6, 54],
889
+ vSE: [8, 54],
890
+ cNW: [6, 55],
891
+ cNE: [7, 55],
892
+ cSW: [6, 56],
893
+ cSE: [7, 56],
894
+ dNWSE: [8, 55],
895
+ dSWNE: [8, 56]
896
+ };
897
+ var OVERLAY_RATE = 5;
898
+ var ROCK_COLOR_OFF = [0, 6, 12];
899
+ var STONE_SIZES = [[16, 51], [16, 52], [16, 53], [16, 54], [16, 55], [16, 56]];
900
+ var ROCK_FOLIAGE = [[17, 51], [18, 51], [19, 51], [20, 51]];
901
+ var ROCK_DENSE = [[18, 53], [19, 53], [18, 54], [19, 54], [18, 55], [19, 55]];
902
+ var ROCK_SPARSE = [
903
+ [17, 52],
904
+ [18, 52],
905
+ [19, 52],
906
+ [20, 52],
907
+ [17, 53],
908
+ [20, 53],
909
+ [17, 54],
910
+ [20, 54],
911
+ [17, 55],
912
+ [20, 55],
913
+ [17, 56],
914
+ [18, 56],
915
+ [19, 56],
916
+ [20, 56]
917
+ ];
918
+ var ROCK_STONE_RATE = 28;
919
+ var ROCK_FOLIAGE_RATE = 12;
920
+ var COL_DIRT = [150, 128, 95];
921
+ var COL_DDIRT = [106, 90, 79];
922
+ var COL_GRASS = [96, 132, 58];
923
+ function chunkFields(seed, x0, y0) {
924
+ const M = 6, SZ = CHUNK + 2 * M;
925
+ const dd = cleanField((lx, ly) => isDarkerDirt(seed, x0 + lx, y0 + ly), M, SZ);
926
+ const grRaw = cleanField((lx, ly) => isGrass(seed, x0 + lx, y0 + ly), M, SZ);
927
+ const gr = new Uint8Array(grRaw.length);
928
+ for (let k = 0; k < gr.length; k++) gr[k] = grRaw[k] && !dd[k] ? 1 : 0;
929
+ const rockRaw = cleanField((lx, ly) => isRock(seed, x0 + lx, y0 + ly), M, SZ);
930
+ const rock = new Uint8Array(rockRaw.length);
931
+ for (let k = 0; k < rock.length; k++) rock[k] = rockRaw[k] && !dd[k] && !gr[k] ? 1 : 0;
932
+ return { dd, gr, rock, M, SZ, x0, y0 };
933
+ }
934
+ var ORC_GROUND_FILLS = [
935
+ { key: "dirt", url: TILES, tile: DIRT_BASE },
936
+ { key: "ddirt", url: TILES, tile: DDIRT.fill },
937
+ { key: "grass", url: TILES, tile: GRASS.fill }
938
+ ];
939
+ function orcGroundFillKey(seed, x, y) {
940
+ if (isDarkerDirt(seed, x, y)) return "ddirt";
941
+ if (isGrass(seed, x, y)) return "grass";
942
+ return "dirt";
943
+ }
944
+ var orcConfig = (seed) => ({
945
+ seed,
946
+ tile: TILE2,
947
+ chunk: CHUNK,
948
+ background: "#69636f",
949
+ async load({ Assets }) {
950
+ const t = await Assets.load(TILES);
951
+ t.source.scaleMode = "nearest";
952
+ return { tiles: t.source };
953
+ },
954
+ // `accept(tx,ty)` (chunk-local) gates which tiles this biome paints — defaults to all, so the
955
+ // standalone map is unchanged; the multi-biome overworld passes a per-biome dither mask.
956
+ bake({ x0, y0, seed: seed2, ctx, add, accept = () => true }) {
957
+ const f = chunkFields(seed2, x0, y0);
958
+ const { dd, gr, rock, M, SZ } = f;
959
+ const idx = (i, j) => j * SZ + i;
960
+ const at = (fld, lx, ly) => fld[idx(lx + M, ly + M)] === 1;
961
+ const atRock = (lx, ly) => rock[idx(lx + M, ly + M)] === 1;
962
+ const place = (coord, tx, ty) => add(ctx.tiles, coord[0], coord[1], tx, ty);
963
+ const drawLayer = (field, set, tx, ty) => {
964
+ if (!at(field, tx, ty)) return;
965
+ const N = !at(field, tx, ty - 1), E = !at(field, tx + 1, ty), S = !at(field, tx, ty + 1), W = !at(field, tx - 1, ty);
966
+ const NW = !at(field, tx - 1, ty - 1), NE = !at(field, tx + 1, ty - 1), SW = !at(field, tx - 1, ty + 1), SE = !at(field, tx + 1, ty + 1);
967
+ const t = autotile(set, N, E, S, W, NW, NE, SW, SE);
968
+ place(t || sparse(set.fill, set.vars, x0 + tx, y0 + ty, set === GRASS ? 1 : 2, OVERLAY_RATE, seed2), tx, ty);
969
+ };
970
+ for (let ty = 0; ty < CHUNK; ty++) {
971
+ for (let tx = 0; tx < CHUNK; tx++) {
972
+ if (!accept(tx, ty)) continue;
973
+ place(sparse(DIRT_BASE, DIRT_VARS, x0 + tx, y0 + ty, 0, DIRT_RATE, seed2), tx, ty);
974
+ drawLayer(dd, DDIRT, tx, ty);
975
+ drawLayer(gr, GRASS, tx, ty);
976
+ if (atRock(tx, ty)) {
977
+ const h = rhash(x0 + tx, y0 + ty, 3, seed2);
978
+ const interior = atRock(tx, ty - 1) && atRock(tx + 1, ty) && atRock(tx, ty + 1) && atRock(tx - 1, ty);
979
+ const pool = interior ? ROCK_DENSE : ROCK_SPARSE;
980
+ place(offset(pool[(h >>> 2) % pool.length], ROCK_COLOR_OFF[h % 3]), tx, ty);
981
+ } else if (!at(dd, tx, ty) && !at(gr, tx, ty)) {
982
+ const h = rhash(x0 + tx, y0 + ty, 7, seed2);
983
+ if (h % ROCK_STONE_RATE === 0) place(offset(STONE_SIZES[(h >>> 3) % STONE_SIZES.length], ROCK_COLOR_OFF[(h >>> 6) % 3]), tx, ty);
984
+ else if ((h >>> 8) % ROCK_FOLIAGE_RATE === 0) place(offset(ROCK_FOLIAGE[(h >>> 11) % ROCK_FOLIAGE.length], ROCK_COLOR_OFF[(h >>> 14) % 3]), tx, ty);
985
+ }
986
+ }
987
+ }
988
+ return { meta: f };
989
+ },
990
+ macroColor(seed2, tx, ty) {
991
+ if (isDarkerDirt(seed2, tx, ty)) return COL_DDIRT;
992
+ if (isGrass(seed2, tx, ty)) return COL_GRASS;
993
+ return COL_DIRT;
994
+ },
995
+ // Top-most ground tile [c,r] at world (wx,wy) for the grid overlay (rocks omitted).
996
+ tileIndexAt(wx, wy, meta) {
997
+ if (!meta) return null;
998
+ const { dd, gr, M, SZ, x0, y0 } = meta;
999
+ const lx = wx - x0, ly = wy - y0;
1000
+ if (lx < 0 || ly < 0 || lx >= CHUNK || ly >= CHUNK) return null;
1001
+ const at = (f, x, y) => f[(y + M) * SZ + (x + M)] === 1;
1002
+ for (const [field, set] of [[gr, GRASS], [dd, DDIRT]]) {
1003
+ if (!at(field, lx, ly)) continue;
1004
+ const N = !at(field, lx, ly - 1), E = !at(field, lx + 1, ly), S = !at(field, lx, ly + 1), W = !at(field, lx - 1, ly);
1005
+ const NW = !at(field, lx - 1, ly - 1), NE = !at(field, lx + 1, ly - 1), SW = !at(field, lx - 1, ly + 1), SE = !at(field, lx + 1, ly + 1);
1006
+ return autotile(set, N, E, S, W, NW, NE, SW, SE) || set.fill;
1007
+ }
1008
+ return DIRT_BASE;
1009
+ }
1010
+ });
1011
+
1012
+ // ../auto-battler/src/engine/fpGen.js
1013
+ var sub3 = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
1014
+ var DIRT_SCALE = 0.04;
1015
+ var DIRT_THRESHOLD = 0.56;
1016
+ function isDirt(seed, x, y) {
1017
+ return fbm(sub3(seed, 53543), x * DIRT_SCALE, y * DIRT_SCALE) > DIRT_THRESHOLD;
1018
+ }
1019
+ var STONE_SCALE = 0.085;
1020
+ var STONE_THRESHOLD = 0.66;
1021
+ function isStone(seed, x, y) {
1022
+ return fbm(sub3(seed, 22286), x * STONE_SCALE, y * STONE_SCALE) > STONE_THRESHOLD;
1023
+ }
1024
+ var FOREST_SCALE = 0.05;
1025
+ function forestField(seed, x, y) {
1026
+ return fbm(sub3(seed, 15740503), x * FOREST_SCALE, y * FOREST_SCALE);
1027
+ }
1028
+ var STONE_CLUMP_SCALE = 0.11;
1029
+ function stoneClumpField(seed, x, y) {
1030
+ return fbm(sub3(seed, 5702849), x * STONE_CLUMP_SCALE, y * STONE_CLUMP_SCALE);
1031
+ }
1032
+ var ELEV_SCALE = 0.025;
1033
+ var ELEV_THRESHOLD = 0.6;
1034
+ function isRaised(seed, x, y) {
1035
+ return fbm(sub3(seed, 57836), x * ELEV_SCALE, y * ELEV_SCALE) > ELEV_THRESHOLD;
1036
+ }
1037
+ var RIVER_SCALE = 0.022;
1038
+ var RIVER_WIDTH = 0.035;
1039
+ var WARP_SCALE2 = 0.03;
1040
+ var WARP_AMP2 = 22;
1041
+ function isRiver(seed, x, y) {
1042
+ const wx = x + WARP_AMP2 * (fbm(sub3(seed, 1297), x * WARP_SCALE2, y * WARP_SCALE2) - 0.5);
1043
+ const wy = y + WARP_AMP2 * (fbm(sub3(seed, 1298), x * WARP_SCALE2, y * WARP_SCALE2) - 0.5);
1044
+ return Math.abs(fbm(sub3(seed, 8654), wx * RIVER_SCALE, wy * RIVER_SCALE) - 0.5) < RIVER_WIDTH;
1045
+ }
1046
+
1047
  // ../auto-battler/src/render/forgottenPlains.js
1048
  var FP = "/assets/minifantasy/Minifantasy_ForgottenPlains_v3.6_Commercial_Version/Minifantasy_ForgottenPlains_Assets";
1049
+ var TILES2 = `${FP}/Tileset/Minifantasy_ForgottenPlainsTiles.png`;
1050
  var SHADOW = `${FP}/Tileset/Minifantasy_ForgottenPlainsTilesShadows.png`;
1051
  var PROPS = `${FP}/props/Minifantasy_ForgottenPlainsProps.png`;
1052
  var PROP_SHADOW = `${FP}/props/Minifantasy_ForgottenPlainsPropsShadows.png`;
1053
  var FP_MOCKUP_URL = `${FP}/Minifantasy_ForgottenPlainsMockup.png`;
1054
+ var TILE3 = 8;
1055
+ var CHUNK2 = 32;
1056
  var GRASS_BASE = [37, 11];
1057
  var GRASS_VARS = [...rect(1, 1, 4, 1), ...rect(2, 3, 3, 5)];
1058
  var GRASS_RATE = 9;
1059
  var DIRT_BLOCK = [7, 3];
1060
  var DIRT_FILL = [8, 4];
1061
+ var DIRT_VARS2 = [[7, 1], [8, 1], [9, 1]];
1062
  var STONE_BLOCK = [12, 3];
1063
  var STONE_FILL = [13, 4];
1064
  var STONE_VARS = [[12, 1], [13, 1], [14, 1]];
 
1196
  var STONE_CLUMP_THRESHOLD = 0.7;
1197
  var STONE_CLUMP_MIN_NB = 3;
1198
  var STONE_CLUMP_FILL = 0.7;
1199
+ var COL_GRASS2 = [97, 150, 55];
1200
+ var COL_DIRT2 = [118, 80, 38];
1201
  var COL_STONE = [120, 120, 122];
1202
  var COL_WATER = [74, 116, 196];
1203
  function loadImg(url) {
 
1215
  cv.height = img.naturalHeight;
1216
  const g = cv.getContext("2d", { willReadFrequently: true });
1217
  g.drawImage(img, 0, 0);
1218
+ const cols = img.naturalWidth / TILE3 | 0, rows = img.naturalHeight / TILE3 | 0, set = /* @__PURE__ */ new Set();
1219
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
1220
+ const d = g.getImageData(c * TILE3, r * TILE3, TILE3, TILE3).data;
1221
  for (let i = 3; i < d.length; i += 4) {
1222
  if (d[i] > 8) {
1223
  set.add(c + "," + r);
 
1227
  }
1228
  return set;
1229
  }
1230
+ function chunkFields2(seed, x0, y0) {
1231
  const WATER_BUFFER = 2;
1232
+ const M = WATER_BUFFER + 4, SZ = CHUNK2 + 2 * M;
1233
  const idx = (i, j) => j * SZ + i;
1234
  const clean = (pred) => {
1235
  let cur = new Uint8Array(SZ * SZ);
 
1287
  }
1288
  return { waterF, dirt, stone, raisedField, forestRegion, stoneClumpRegion, M, SZ, x0, y0 };
1289
  }
1290
+ var FP_GROUND_FILLS = [
1291
+ { key: "grass", url: TILES2, tile: GRASS_BASE },
1292
+ { key: "dirt", url: TILES2, tile: DIRT_FILL },
1293
+ { key: "stone", url: TILES2, tile: STONE_FILL }
1294
+ ];
1295
+ function fpGroundFillKey(seed, x, y) {
1296
+ if (isStone(seed, x, y) && !isDirt(seed, x, y)) return "stone";
1297
+ if (isDirt(seed, x, y)) return "dirt";
1298
+ return "grass";
1299
+ }
1300
  var fpConfig = (seed) => ({
1301
  seed,
1302
+ tile: TILE3,
1303
+ chunk: CHUNK2,
1304
  background: "#5a7b3a",
1305
  async load({ Assets }) {
1306
  const ctx = { tiles: null, shadow: null, shadowSet: null, props: null, propShadow: null };
1307
+ const t = await Assets.load(TILES2);
1308
  t.source.scaleMode = "nearest";
1309
  ctx.tiles = t.source;
1310
  try {
 
1335
  // `accept(tx,ty)` (chunk-local) gates which tiles this biome paints — defaults to all, so the
1336
  // standalone map is unchanged; the multi-biome overworld passes a per-biome dither mask.
1337
  bake({ x0, y0, seed: seed2, ctx, tmp, Sprite, tex, texFrame, add, accept = () => true }) {
1338
+ const f = chunkFields2(seed2, x0, y0);
1339
  const { waterF, dirt, stone, raisedField, forestRegion, stoneClumpRegion, M, SZ } = f;
1340
  const idx = (i, j) => j * SZ + i;
1341
  const atW = (wx, wy) => waterF[idx(wx - x0 + M, wy - y0 + M)] === 1;
 
1346
  add(ctx.tiles, coord[0], coord[1], tx, ty);
1347
  if (ctx.shadow && (!ctx.shadowSet || ctx.shadowSet.has(coord[0] + "," + coord[1]))) {
1348
  const sh = new Sprite(tex(ctx.shadow, coord[0], coord[1]));
1349
+ sh.x = tx * TILE3;
1350
+ sh.y = ty * TILE3;
1351
  tmp.addChild(sh);
1352
  }
1353
  };
 
1368
  } else cr = blockTile(BLOCK, N, E, S, W);
1369
  place(cr, tx, ty);
1370
  };
1371
+ for (let ty = 0; ty < CHUNK2; ty++) for (let tx = 0; tx < CHUNK2; tx++) {
1372
  if (!accept(tx, ty)) continue;
1373
  const wx = x0 + tx, wy = y0 + ty;
1374
  place(sparse(GRASS_BASE, GRASS_VARS, wx, wy, 0, GRASS_RATE, seed2), tx, ty);
1375
+ if (atD(wx, wy)) blob(atD, DIRT_BLOCK, DIRT_FILL, DIRT_VARS2, 1, wx, wy, tx, ty);
1376
  if (atS(wx, wy)) blob(atS, STONE_BLOCK, STONE_FILL, STONE_VARS, 2, wx, wy, tx, ty);
1377
  if (atW(wx, wy)) blob(atW, WATER_BLOCK, WATER_FILL, WATER_VARS, 3, wx, wy, tx, ty);
1378
  }
1379
  const live = [];
1380
  const propSprite = (spec, wx, wy) => {
1381
+ const sp = new Sprite(texFrame(ctx.props, spec.c * TILE3, (spec.r - spec.h + 1) * TILE3, spec.w * TILE3, spec.h * TILE3));
1382
  sp.anchor.set(0.5, 1);
1383
+ sp.x = wx * TILE3 + TILE3 / 2;
1384
+ sp.y = (wy + 1) * TILE3;
1385
+ sp.zIndex = (wy + 1) * TILE3;
1386
  return sp;
1387
  };
1388
+ if (ctx.props) for (let ty = 0; ty < CHUNK2; ty++) for (let tx = 0; tx < CHUNK2; tx++) {
1389
  if (!accept(tx, ty)) continue;
1390
  const wx = x0 + tx, wy = y0 + ty;
1391
  if (atW(wx, wy)) {
 
1408
  continue;
1409
  }
1410
  const [c, r] = g.tiles[(h >>> 16) % g.tiles.length];
1411
+ const sp = new Sprite(texFrame(ctx.props, c * TILE3, r * TILE3, TILE3, TILE3));
1412
+ sp.x = tx * TILE3;
1413
+ sp.y = ty * TILE3;
1414
  tmp.addChild(sp);
1415
  }
1416
  const stairAt = (wx, wy) => {
 
1421
  const off = hashU32(seed2 ^ 358940, Math.floor(wx / STAIR_SPACING), 0) % STAIR_SPACING;
1422
  return ((wy - off) % STAIR_SPACING + STAIR_SPACING) % STAIR_SPACING === 0;
1423
  };
1424
+ bakeCliffs(FP_CLIFF, { chunk: CHUNK2, x0, y0, seed: seed2, raised: isRaisedAt, place, accept, stairAt, stairAtV });
1425
  if (ctx.props) {
1426
  const inGrove = (wx, wy) => forestRegion[idx(wx - x0 + M, wy - y0 + M)] === 1;
1427
+ const bx0 = Math.floor(x0 / FOREST_BLOCK_X), bx1 = Math.floor((x0 + CHUNK2 - 1) / FOREST_BLOCK_X);
1428
+ const by0 = Math.floor(y0 / FOREST_BLOCK_Y), by1 = Math.floor((y0 + CHUNK2 - 1) / FOREST_BLOCK_Y);
1429
  for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
1430
  const h = hashU32(seed2 ^ 15748695, bx, by);
1431
  const wx = bx * FOREST_BLOCK_X + h % FOREST_BLOCK_X, wy = by * FOREST_BLOCK_Y + (h >>> 4) % FOREST_BLOCK_Y;
1432
+ if (wx < x0 || wx >= x0 + CHUNK2 || wy < y0 || wy >= y0 + CHUNK2) continue;
1433
  if (!accept(wx - x0, wy - y0)) continue;
1434
  if (!inGrove(wx, wy)) continue;
1435
  if ((h >>> 8 & 65535) / 65536 >= FOREST_FILL) continue;
 
1445
  const T = TREES[(h >>> 24) % TREES.length];
1446
  const flip = (h >>> 20 & 255) / 256 < TREE_FLIP_RATE;
1447
  const sc = TREE_SCALE_BASE * (1 + ((h >>> 12 & 255) / 256 - 0.5) * 2 * TREE_SCALE_JITTER);
1448
+ const px = wx * TILE3 + TILE3 / 2 + ((h >>> 16 & 15) / 16 - 0.5) * 2 * TREE_JITTER;
1449
+ const py = wy * TILE3 + TILE3 / 2 + ((h >>> 28 & 15) / 16 - 0.5) * 2 * TREE_JITTER;
1450
  const tr = new Sprite(texFrame(ctx.props, ...T.frame));
1451
  tr.anchor.set(T.ax, T.ay);
1452
  tr.scale.set(flip ? -sc : sc, sc);
 
1466
  }
1467
  if (ctx.props) {
1468
  const inClump = (wx, wy) => stoneClumpRegion[idx(wx - x0 + M, wy - y0 + M)] === 1;
1469
+ const sbx0 = Math.floor(x0 / STONE_BLOCK_X), sbx1 = Math.floor((x0 + CHUNK2 - 1) / STONE_BLOCK_X);
1470
+ const sby0 = Math.floor(y0 / STONE_BLOCK_Y), sby1 = Math.floor((y0 + CHUNK2 - 1) / STONE_BLOCK_Y);
1471
  for (let by = sby0; by <= sby1; by++) for (let bx = sbx0; bx <= sbx1; bx++) {
1472
  const h = hashU32(seed2 ^ 5702885, bx, by);
1473
  const wx = bx * STONE_BLOCK_X + h % STONE_BLOCK_X, wy = by * STONE_BLOCK_Y + (h >>> 4) % STONE_BLOCK_Y;
1474
+ if (wx < x0 || wx >= x0 + CHUNK2 || wy < y0 || wy >= y0 + CHUNK2) continue;
1475
  if (!accept(wx - x0, wy - y0)) continue;
1476
  if (!inClump(wx, wy)) continue;
1477
  if ((h >>> 8 & 65535) / 65536 >= STONE_CLUMP_FILL) continue;
 
1500
  macroColor(seed2, tx, ty) {
1501
  const raised = isRaised(seed2, tx, ty);
1502
  if (!raised && isRiver(seed2, tx, ty)) return COL_WATER;
1503
+ let c = isStone(seed2, tx, ty) && !isDirt(seed2, tx, ty) ? COL_STONE : isDirt(seed2, tx, ty) ? COL_DIRT2 : COL_GRASS2;
1504
  if (raised) c = lerp3(c, COL_CLIFF, 0.25);
1505
  return c;
1506
  },
1507
  tileIndexAt(wx, wy, meta) {
1508
  if (!meta) return null;
1509
  const { waterF, dirt, stone, M, SZ, x0, y0 } = meta;
1510
+ if (wx - x0 < 0 || wy - y0 < 0 || wx - x0 >= CHUNK2 || wy - y0 >= CHUNK2) return null;
1511
  const idx = (i, j) => j * SZ + i;
1512
  const pick = (fld, BLOCK, fill) => {
1513
  const at = (x, y) => fld[idx(x - x0 + M, y - y0 + M)] === 1;
 
1526
  return GRASS_BASE;
1527
  }
1528
  });
1529
+
1530
+ // ../auto-battler/src/engine/necropolisGen.js
1531
+ var sub4 = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
1532
+ var RIVER_SCALE2 = 0.045;
1533
+ var RIVER_WIDTH2 = 0.05;
1534
+ var WARP_SCALE3 = 0.03;
1535
+ var WARP_AMP3 = 16;
1536
+ var DARK_SCALE = 0.06;
1537
+ var DARK_THRESHOLD = 0.6;
1538
+ function isDark(seed, x, y) {
1539
+ return fbm(sub4(seed, 55852), x * DARK_SCALE, y * DARK_SCALE) > DARK_THRESHOLD;
1540
+ }
1541
+ var ELEV_SCALE2 = 0.025;
1542
+ var ELEV_THRESHOLD2 = 0.6;
1543
+ function isRaised2(seed, x, y) {
1544
+ return fbm(sub4(seed, 57836), x * ELEV_SCALE2, y * ELEV_SCALE2) > ELEV_THRESHOLD2;
1545
+ }
1546
+ var BONE_SCALE = 0.055;
1547
+ var BONE_THRESHOLD = 0.62;
1548
+ function isBone(seed, x, y) {
1549
+ return fbm(sub4(seed, 45134), x * BONE_SCALE, y * BONE_SCALE) > BONE_THRESHOLD;
1550
+ }
1551
+ var FOREST_SCALE2 = 0.05;
1552
+ function forestField2(seed, x, y) {
1553
+ return fbm(sub4(seed, 15740503), x * FOREST_SCALE2, y * FOREST_SCALE2);
1554
+ }
1555
+ function isRiver2(seed, x, y) {
1556
+ const wx = x + WARP_AMP3 * (fbm(sub4(seed, 1297), x * WARP_SCALE3, y * WARP_SCALE3) - 0.5);
1557
+ const wy = y + WARP_AMP3 * (fbm(sub4(seed, 1298), x * WARP_SCALE3, y * WARP_SCALE3) - 0.5);
1558
+ return Math.abs(fbm(sub4(seed, 8654), wx * RIVER_SCALE2, wy * RIVER_SCALE2) - 0.5) < RIVER_WIDTH2;
1559
+ }
1560
+
1561
+ // ../auto-battler/src/render/necropolis.js
1562
+ var NECRO = "/assets/minifantasy/Minifantasy_Necropolis_v1.0/Minifantasy_Necropolis_Assets";
1563
+ var EXT_DIR = `${NECRO}/PremadeScenes/Exterior/SeparateLayers`;
1564
+ var BIOME = `${NECRO}/Tileset/Biome/CorruptedBiome.png`;
1565
+ var SHADOW2 = `${NECRO}/Tileset/Biome/CorruptedBiomeShadows.png`;
1566
+ var PROPS2 = `${NECRO}/Props/Props.png`;
1567
+ var PROP_SHADOW2 = `${NECRO}/Props/PropShadows.png`;
1568
+ var TILE4 = 8;
1569
+ var CHUNK3 = 32;
1570
+ var TREES2 = [
1571
+ { frame: [9, 15, 14, 14], shadow: [8, 24, 3, 5], ax: 0.04, ay: 0.82 },
1572
+ // 1,1 (2×3)
1573
+ { frame: [28, 11, 17, 19], shadow: [25, 24, 6, 6], ax: 0, ay: 0.84 },
1574
+ // 3,1 (3×3)
1575
+ { frame: [51, 13, 18, 16], shadow: [55, 24, 7, 5], ax: 0.42, ay: 0.84 },
1576
+ // 6,1 (3×3)
1577
+ { frame: [75, 14, 10, 15], shadow: [72, 25, 7, 4], ax: 0.05, ay: 0.87 }
1578
+ // 9,1 (2×3)
1579
+ ];
1580
+ var TREE_SCALE_BASE2 = 1;
1581
+ var TREE_SCALE_JITTER2 = 0.15;
1582
+ var TREE_FLIP_RATE2 = 0.5;
1583
+ var TREE_JITTER2 = 2;
1584
+ var FOREST_BLOCK_X2 = 2;
1585
+ var FOREST_BLOCK_Y2 = 1;
1586
+ var FOREST_THRESHOLD2 = 0.62;
1587
+ var FOREST_MIN_NB2 = 2;
1588
+ var FOREST_FILL2 = 0.55;
1589
+ var TREE_WATER_BUFFER = 1;
1590
+ var FOLIAGE2 = [
1591
+ { tiles: [[1, 5], [2, 5], [1, 6], [2, 6]], shadow: false, weight: 3 },
1592
+ // grass tufts (most common)
1593
+ { tiles: [[6, 5], [7, 5], [6, 6], [7, 6]], shadow: true, weight: 1 },
1594
+ // species A
1595
+ { tiles: [[9, 5], [10, 5], [9, 6], [10, 6]], shadow: true, weight: 1 }
1596
+ // species B
1597
+ ];
1598
+ var FOLIAGE_WEIGHT2 = FOLIAGE2.reduce((s, g) => s + g.weight, 0);
1599
+ var FOLIAGE_RATE2 = 12;
1600
+ var LIP_T = [2, 10];
1601
+ var LIP_T_VAR = [[3, 10], [4, 10], [5, 10]];
1602
+ var LIP_TL = [1, 10];
1603
+ var LIP_TR = [6, 10];
1604
+ var WALL_L = [1, 14];
1605
+ var WALL_L_VAR = [[1, 11], [1, 12], [1, 13]];
1606
+ var WALL_R = [6, 14];
1607
+ var WALL_R_VAR = [[6, 11], [6, 12], [6, 13]];
1608
+ var LIP_S = [2, 15];
1609
+ var LIP_S_VAR = [[3, 15], [4, 15], [5, 15]];
1610
+ var LIP_SW = [1, 15];
1611
+ var LIP_SE = [6, 15];
1612
+ var FACE_H = 2;
1613
+ var FACE = [[3, 16], [3, 17]];
1614
+ var FACE_VAR = [[[2, 16], [4, 16], [5, 16]], [[2, 17], [4, 17], [5, 17]]];
1615
+ var FACE_L = [[1, 16], [1, 17]];
1616
+ var FACE_R = [[6, 16], [6, 17]];
1617
+ var CLIFF_SPARSE_RATE = 2;
1618
+ var NECRO_CLIFF = {
1619
+ LIP_T,
1620
+ LIP_T_VAR,
1621
+ LIP_TL,
1622
+ LIP_TR,
1623
+ WALL_L,
1624
+ WALL_L_VAR,
1625
+ WALL_R,
1626
+ WALL_R_VAR,
1627
+ LIP_S,
1628
+ LIP_S_VAR,
1629
+ LIP_SW,
1630
+ LIP_SE,
1631
+ FACE_H,
1632
+ FACE,
1633
+ FACE_VAR,
1634
+ FACE_L,
1635
+ FACE_R,
1636
+ RATE: CLIFF_SPARSE_RATE
1637
+ };
1638
+ var CORRUPT_LIGHT = [1, 1];
1639
+ var CORRUPT_DARK = [2, 1];
1640
+ var NECRO_GROUND_FILLS = [
1641
+ { key: "light", url: BIOME, tile: CORRUPT_LIGHT },
1642
+ { key: "dark", url: BIOME, tile: CORRUPT_DARK }
1643
+ ];
1644
+ function necroGroundFillKey(seed, x, y) {
1645
+ return isDark(seed, x, y) ? "dark" : "light";
1646
+ }
1647
+ var LIGHT_VARIANTS = [[1, 2], [2, 2], [1, 3], [2, 3], [1, 4], [2, 4], [1, 5], [2, 5]];
1648
+ var LIGHT_SPARSE_RATE = 10;
1649
+ var WATER_BLOCK2 = [4, 2];
1650
+ var BONE_BLOCK = [13, 2];
1651
+ var BONE_FILL = [[12, 1], [13, 1], [14, 1], [15, 1]];
1652
+ var EDGE_N = [18, 4];
1653
+ var EDGE_S = [18, 2];
1654
+ var EDGE_E = [17, 3];
1655
+ var EDGE_W = [19, 3];
1656
+ var CONV_NW = [18, 6];
1657
+ var CONV_NE = [17, 6];
1658
+ var CONV_SW = [18, 5];
1659
+ var CONV_SE = [17, 5];
1660
+ var CONC_NW = [19, 4];
1661
+ var CONC_NE = [17, 4];
1662
+ var CONC_SW = [19, 2];
1663
+ var CONC_SE = [17, 2];
1664
+ var CONC_NWSE = [19, 6];
1665
+ var CONC_NESW = [19, 5];
1666
+ var COL_LIGHT = [130, 119, 136];
1667
+ var COL_DARK = [105, 99, 113];
1668
+ var COL_WATER2 = [74, 142, 48];
1669
+ var COL_BONE = [180, 156, 126];
1670
+ var COL_CLIFF2 = [175, 146, 109];
1671
+ var COL_FOREST = [96, 101, 70];
1672
+ function corruptTile(N, E, S, W, NW, NE, SW, SE) {
1673
+ const card = (N ? 1 : 0) + (E ? 1 : 0) + (S ? 1 : 0) + (W ? 1 : 0);
1674
+ if (card === 1) return N ? EDGE_N : S ? EDGE_S : E ? EDGE_E : EDGE_W;
1675
+ if (card === 2) {
1676
+ if (N && W) return CONV_NW;
1677
+ if (N && E) return CONV_NE;
1678
+ if (S && W) return CONV_SW;
1679
+ if (S && E) return CONV_SE;
1680
+ return N ? EDGE_N : EDGE_E;
1681
+ }
1682
+ if (card >= 3) return N ? EDGE_N : S ? EDGE_S : E ? EDGE_E : EDGE_W;
1683
+ const diag = (NW ? 1 : 0) + (NE ? 1 : 0) + (SW ? 1 : 0) + (SE ? 1 : 0);
1684
+ if (diag === 0) return null;
1685
+ if (diag === 1) return NW ? CONC_NW : NE ? CONC_NE : SW ? CONC_SW : CONC_SE;
1686
+ if (NW && SE && !NE && !SW) return CONC_NWSE;
1687
+ if (NE && SW && !NW && !SE) return CONC_NESW;
1688
+ return null;
1689
+ }
1690
+ function boneCenterTile([c0, r0], dNW, dNE, dSW, dSE) {
1691
+ const n = dNW + dNE + dSW + dSE;
1692
+ if (n === 1) {
1693
+ if (dNW) return [c0, r0 + 3];
1694
+ if (dNE) return [c0 + 1, r0 + 3];
1695
+ if (dSW) return [c0, r0 + 4];
1696
+ return [c0 + 1, r0 + 4];
1697
+ }
1698
+ if (dNE && dSW && !dNW && !dSE) return [c0 + 2, r0 + 4];
1699
+ if (dNW && dSE && !dNE && !dSW) return [c0 + 2, r0 + 3];
1700
+ return [c0 + 1, r0 + 1];
1701
+ }
1702
+ function biomeColor(seed, tx, ty) {
1703
+ const raised = isRaised2(seed, tx, ty);
1704
+ if (!raised && isRiver2(seed, tx, ty)) return COL_WATER2;
1705
+ if (isBone(seed, tx, ty)) return COL_BONE;
1706
+ let c = isDark(seed, tx, ty) ? COL_DARK : COL_LIGHT;
1707
+ if (forestField2(seed, tx, ty) > FOREST_THRESHOLD2) c = lerp3(c, COL_FOREST, 0.5);
1708
+ if (raised) c = lerp3(c, COL_CLIFF2, 0.22);
1709
+ return c;
1710
+ }
1711
+ function loadImg2(url) {
1712
+ return new Promise((resolve, reject) => {
1713
+ const img = new Image();
1714
+ img.onload = () => resolve(img);
1715
+ img.onerror = reject;
1716
+ img.src = url;
1717
+ });
1718
+ }
1719
+ async function buildShadowMask2(url) {
1720
+ const img = await loadImg2(url);
1721
+ const cv = document.createElement("canvas");
1722
+ cv.width = img.naturalWidth;
1723
+ cv.height = img.naturalHeight;
1724
+ const g = cv.getContext("2d", { willReadFrequently: true });
1725
+ g.drawImage(img, 0, 0);
1726
+ const cols = img.naturalWidth / TILE4 | 0, rows = img.naturalHeight / TILE4 | 0, set = /* @__PURE__ */ new Set();
1727
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
1728
+ const d = g.getImageData(c * TILE4, r * TILE4, TILE4, TILE4).data;
1729
+ for (let i = 3; i < d.length; i += 4) {
1730
+ if (d[i] > 8) {
1731
+ set.add(c + "," + r);
1732
+ break;
1733
+ }
1734
+ }
1735
+ }
1736
+ return set;
1737
+ }
1738
+ function chunkFields3(seed, x0, y0) {
1739
+ const WATER_BUFFER = 2;
1740
+ const ELEV_BUFFER = 3;
1741
+ const M = Math.max(WATER_BUFFER, ELEV_BUFFER) + 4, SZ = CHUNK3 + 2 * M;
1742
+ const idx = (i, j) => j * SZ + i;
1743
+ const clean = (pred) => {
1744
+ let cur = new Uint8Array(SZ * SZ);
1745
+ for (let j = 0; j < SZ; j++) for (let i = 0; i < SZ; i++) cur[idx(i, j)] = pred(x0 + i - M, y0 + j - M) ? 1 : 0;
1746
+ let nxt = new Uint8Array(SZ * SZ);
1747
+ for (let j = 1; j < SZ - 1; j++) for (let i = 1; i < SZ - 1; i++) {
1748
+ let c = 0;
1749
+ for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) c += cur[idx(i + di, j + dj)];
1750
+ nxt[idx(i, j)] = c >= 5 ? 1 : 0;
1751
+ }
1752
+ cur = nxt;
1753
+ for (let p = 0; p < 2; p++) {
1754
+ nxt = new Uint8Array(SZ * SZ);
1755
+ for (let j = 1; j < SZ - 1; j++) for (let i = 1; i < SZ - 1; i++) {
1756
+ const card = cur[idx(i, j - 1)] + cur[idx(i + 1, j)] + cur[idx(i, j + 1)] + cur[idx(i - 1, j)];
1757
+ nxt[idx(i, j)] = cur[idx(i, j)] ? card >= 2 ? 1 : 0 : card >= 3 ? 1 : 0;
1758
+ }
1759
+ cur = nxt;
1760
+ }
1761
+ return cur;
1762
+ };
1763
+ const darkRaw = clean((x, y) => isDark(seed, x, y));
1764
+ const waterField = clean((x, y) => isRiver2(seed, x, y));
1765
+ const raisedField = clean((x, y) => isRaised2(seed, x, y));
1766
+ const boneField = clean((x, y) => isBone(seed, x, y));
1767
+ const fraw = new Uint8Array(SZ * SZ);
1768
+ for (let j = 0; j < SZ; j++) for (let i = 0; i < SZ; i++) fraw[idx(i, j)] = forestField2(seed, x0 + i - M, y0 + j - M) > FOREST_THRESHOLD2 ? 1 : 0;
1769
+ const forestRegion = new Uint8Array(SZ * SZ);
1770
+ for (let j = 1; j < SZ - 1; j++) for (let i = 1; i < SZ - 1; i++) {
1771
+ if (!fraw[idx(i, j)]) continue;
1772
+ let c = 0;
1773
+ for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) if ((di || dj) && fraw[idx(i + di, j + dj)]) c++;
1774
+ forestRegion[idx(i, j)] = c >= FOREST_MIN_NB2 ? 1 : 0;
1775
+ }
1776
+ const field = new Uint8Array(SZ * SZ);
1777
+ const boneMask = new Uint8Array(SZ * SZ);
1778
+ const B = Math.max(WATER_BUFFER, ELEV_BUFFER);
1779
+ for (let j = B; j < SZ - B; j++) for (let i = B; i < SZ - B; i++) {
1780
+ let nearW = 0;
1781
+ for (let dj = -WATER_BUFFER; dj <= WATER_BUFFER && !nearW; dj++) for (let di = -WATER_BUFFER; di <= WATER_BUFFER; di++) if (waterField[idx(i + di, j + dj)]) {
1782
+ nearW = 1;
1783
+ break;
1784
+ }
1785
+ let nearCliff = 0;
1786
+ {
1787
+ const me = raisedField[idx(i, j)];
1788
+ for (let dj = -ELEV_BUFFER; dj <= ELEV_BUFFER && !nearCliff; dj++) for (let di = -ELEV_BUFFER; di <= ELEV_BUFFER; di++) if (raisedField[idx(i + di, j + dj)] !== me) {
1789
+ nearCliff = 1;
1790
+ break;
1791
+ }
1792
+ }
1793
+ const water = waterField[idx(i, j)];
1794
+ field[idx(i, j)] = darkRaw[idx(i, j)] && !nearW && !water && !nearCliff ? 0 : 1;
1795
+ boneMask[idx(i, j)] = boneField[idx(i, j)] && !water && !nearW && !nearCliff ? 1 : 0;
1796
+ }
1797
+ for (let pass = 0; pass < 3; pass++) {
1798
+ const prev = boneMask.slice();
1799
+ let changed = false;
1800
+ for (let j = B + 1; j < SZ - B - 1; j++) for (let i = B + 1; i < SZ - B - 1; i++) {
1801
+ if (!prev[idx(i, j)]) continue;
1802
+ const up = prev[idx(i, j - 1)], down = prev[idx(i, j + 1)], left = prev[idx(i - 1, j)], right = prev[idx(i + 1, j)];
1803
+ if (!up && !down || !left && !right) {
1804
+ boneMask[idx(i, j)] = 0;
1805
+ changed = true;
1806
+ }
1807
+ }
1808
+ if (!changed) break;
1809
+ }
1810
+ return { field, boneMask, waterField, raisedField, forestRegion, M, SZ, x0, y0 };
1811
+ }
1812
+ var necropolisConfig = (seed) => ({
1813
+ seed,
1814
+ tile: TILE4,
1815
+ chunk: CHUNK3,
1816
+ background: "#69636f",
1817
+ async load({ Assets }) {
1818
+ const ctx = { biome: null, shadow: null, shadowSet: null, props: null, propShadow: null };
1819
+ const b = await Assets.load(BIOME);
1820
+ b.source.scaleMode = "nearest";
1821
+ ctx.biome = b.source;
1822
+ try {
1823
+ const s = await Assets.load(SHADOW2);
1824
+ s.source.scaleMode = "nearest";
1825
+ ctx.shadow = s.source;
1826
+ } catch {
1827
+ }
1828
+ try {
1829
+ ctx.shadowSet = await buildShadowMask2(SHADOW2);
1830
+ } catch {
1831
+ ctx.shadowSet = null;
1832
+ }
1833
+ try {
1834
+ const p = await Assets.load(PROPS2);
1835
+ p.source.scaleMode = "nearest";
1836
+ ctx.props = p.source;
1837
+ } catch {
1838
+ }
1839
+ try {
1840
+ const p = await Assets.load(PROP_SHADOW2);
1841
+ p.source.scaleMode = "nearest";
1842
+ ctx.propShadow = p.source;
1843
+ } catch {
1844
+ }
1845
+ return ctx;
1846
+ },
1847
+ // `accept(tx,ty)` (chunk-local) gates which tiles this biome paints — defaults to all, so the
1848
+ // standalone map is unchanged; the multi-biome overworld passes a per-biome dither mask.
1849
+ bake({ x0, y0, seed: seed2, ctx, tmp, Sprite, tex, texFrame, add, accept = () => true }) {
1850
+ const f = chunkFields3(seed2, x0, y0);
1851
+ const { field, boneMask, waterField, raisedField, forestRegion, M, SZ } = f;
1852
+ const idx = (i, j) => j * SZ + i;
1853
+ const isLight = (wx, wy) => field[idx(wx - x0 + M, wy - y0 + M)] === 1;
1854
+ const isRaisedAt = (wx, wy) => raisedField[idx(wx - x0 + M, wy - y0 + M)] === 1;
1855
+ const isWater = (wx, wy) => waterField[idx(wx - x0 + M, wy - y0 + M)] === 1 && !isRaisedAt(wx, wy);
1856
+ const boneAt = (wx, wy) => boneMask[idx(wx - x0 + M, wy - y0 + M)] === 1;
1857
+ const place = (coord, tx, ty) => {
1858
+ add(ctx.biome, coord[0], coord[1], tx, ty);
1859
+ if (ctx.shadow && (!ctx.shadowSet || ctx.shadowSet.has(coord[0] + "," + coord[1]))) {
1860
+ const sh = new Sprite(tex(ctx.shadow, coord[0], coord[1]));
1861
+ sh.x = tx * TILE4;
1862
+ sh.y = ty * TILE4;
1863
+ tmp.addChild(sh);
1864
+ }
1865
+ };
1866
+ for (let ty = 0; ty < CHUNK3; ty++) for (let tx = 0; tx < CHUNK3; tx++) {
1867
+ if (!accept(tx, ty)) continue;
1868
+ const wx = x0 + tx, wy = y0 + ty;
1869
+ if (!isLight(wx, wy)) {
1870
+ place(CORRUPT_DARK, tx, ty);
1871
+ continue;
1872
+ }
1873
+ const N = !isLight(wx, wy - 1), E = !isLight(wx + 1, wy), S = !isLight(wx, wy + 1), Wf = !isLight(wx - 1, wy);
1874
+ const NW = !isLight(wx - 1, wy - 1), NE = !isLight(wx + 1, wy - 1), SW = !isLight(wx - 1, wy + 1), SE = !isLight(wx + 1, wy + 1);
1875
+ const t = corruptTile(N, E, S, Wf, NW, NE, SW, SE);
1876
+ if (!t) {
1877
+ place(sparse(CORRUPT_LIGHT, LIGHT_VARIANTS, wx, wy, 0, LIGHT_SPARSE_RATE, seed2), tx, ty);
1878
+ continue;
1879
+ }
1880
+ place(CORRUPT_DARK, tx, ty);
1881
+ place(t, tx, ty);
1882
+ }
1883
+ for (let ty = 0; ty < CHUNK3; ty++) for (let tx = 0; tx < CHUNK3; tx++) {
1884
+ if (!accept(tx, ty)) continue;
1885
+ const wx = x0 + tx, wy = y0 + ty;
1886
+ if (!isWater(wx, wy)) continue;
1887
+ const N = !isWater(wx, wy - 1), E = !isWater(wx + 1, wy), S = !isWater(wx, wy + 1), Wf = !isWater(wx - 1, wy);
1888
+ if (N && E && S && Wf) continue;
1889
+ let cr;
1890
+ if (!N && !E && !S && !Wf) cr = centerTile(WATER_BLOCK2, !isWater(wx - 1, wy - 1), !isWater(wx + 1, wy - 1), !isWater(wx - 1, wy + 1), !isWater(wx + 1, wy + 1));
1891
+ else cr = blockTile(WATER_BLOCK2, N, E, S, Wf);
1892
+ place(cr, tx, ty);
1893
+ }
1894
+ for (let ty = 0; ty < CHUNK3; ty++) for (let tx = 0; tx < CHUNK3; tx++) {
1895
+ if (!accept(tx, ty)) continue;
1896
+ const wx = x0 + tx, wy = y0 + ty;
1897
+ if (!boneAt(wx, wy)) continue;
1898
+ const N = !boneAt(wx, wy - 1), E = !boneAt(wx + 1, wy), S = !boneAt(wx, wy + 1), Wf = !boneAt(wx - 1, wy);
1899
+ if (N && E && S && Wf) {
1900
+ place(sparse(BONE_FILL[0], BONE_FILL, wx, wy, 11, 1, seed2), tx, ty);
1901
+ continue;
1902
+ }
1903
+ let cr;
1904
+ if (!N && !E && !S && !Wf) {
1905
+ const dNW = !boneAt(wx - 1, wy - 1), dNE = !boneAt(wx + 1, wy - 1), dSW = !boneAt(wx - 1, wy + 1), dSE = !boneAt(wx + 1, wy + 1);
1906
+ if (dNW || dNE || dSW || dSE) cr = boneCenterTile(BONE_BLOCK, dNW, dNE, dSW, dSE);
1907
+ else {
1908
+ place(sparse(BONE_FILL[0], BONE_FILL, wx, wy, 11, 1, seed2), tx, ty);
1909
+ continue;
1910
+ }
1911
+ } else cr = blockTile(BONE_BLOCK, N, E, S, Wf);
1912
+ place(cr, tx, ty);
1913
+ }
1914
+ if (ctx.props) for (let ty = 0; ty < CHUNK3; ty++) for (let tx = 0; tx < CHUNK3; tx++) {
1915
+ if (!accept(tx, ty)) continue;
1916
+ const wx = x0 + tx, wy = y0 + ty;
1917
+ if (isWater(wx, wy) || boneAt(wx, wy)) continue;
1918
+ const h = hashU32(seed2 ^ 15733114, wx, wy);
1919
+ if (h % FOLIAGE_RATE2 !== 0) continue;
1920
+ let pick = (h >>> 8) % FOLIAGE_WEIGHT2, g = FOLIAGE2[0];
1921
+ for (const grp of FOLIAGE2) {
1922
+ if (pick < grp.weight) {
1923
+ g = grp;
1924
+ break;
1925
+ }
1926
+ pick -= grp.weight;
1927
+ }
1928
+ const [c, r] = g.tiles[(h >>> 16) % g.tiles.length];
1929
+ if (g.shadow && ctx.propShadow) {
1930
+ const sh = new Sprite(texFrame(ctx.propShadow, c * TILE4, r * TILE4, TILE4, TILE4));
1931
+ sh.x = tx * TILE4;
1932
+ sh.y = ty * TILE4;
1933
+ tmp.addChild(sh);
1934
+ }
1935
+ const sp = new Sprite(texFrame(ctx.props, c * TILE4, r * TILE4, TILE4, TILE4));
1936
+ sp.x = tx * TILE4;
1937
+ sp.y = ty * TILE4;
1938
+ tmp.addChild(sp);
1939
+ }
1940
+ bakeCliffs(NECRO_CLIFF, { chunk: CHUNK3, x0, y0, seed: seed2, raised: isRaisedAt, place, accept });
1941
+ const live = [];
1942
+ if (ctx.props) {
1943
+ const nearWater = (wx, wy) => {
1944
+ for (let dj = -TREE_WATER_BUFFER; dj <= TREE_WATER_BUFFER; dj++) for (let di = -TREE_WATER_BUFFER; di <= TREE_WATER_BUFFER; di++) if (waterField[idx(wx + di - x0 + M, wy + dj - y0 + M)]) return true;
1945
+ return false;
1946
+ };
1947
+ const nearCliff = (wx, wy) => {
1948
+ const me = isRaisedAt(wx, wy);
1949
+ for (let dj = -3; dj <= 3; dj++) for (let di = -3; di <= 3; di++) if (isRaisedAt(wx + di, wy + dj) !== me) return true;
1950
+ return false;
1951
+ };
1952
+ const bx0 = Math.floor(x0 / FOREST_BLOCK_X2), bx1 = Math.floor((x0 + CHUNK3 - 1) / FOREST_BLOCK_X2);
1953
+ const by0 = Math.floor(y0 / FOREST_BLOCK_Y2), by1 = Math.floor((y0 + CHUNK3 - 1) / FOREST_BLOCK_Y2);
1954
+ for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
1955
+ const h = hashU32(seed2 ^ 15748695, bx, by);
1956
+ const wx = bx * FOREST_BLOCK_X2 + h % FOREST_BLOCK_X2, wy = by * FOREST_BLOCK_Y2 + (h >>> 4) % FOREST_BLOCK_Y2;
1957
+ if (wx < x0 || wx >= x0 + CHUNK3 || wy < y0 || wy >= y0 + CHUNK3) continue;
1958
+ if (!accept(wx - x0, wy - y0)) continue;
1959
+ if (forestRegion[idx(wx - x0 + M, wy - y0 + M)] !== 1) continue;
1960
+ if ((h >>> 8 & 65535) / 65536 >= FOREST_FILL2) continue;
1961
+ if (nearWater(wx, wy) || boneAt(wx, wy) || nearCliff(wx, wy)) continue;
1962
+ const T = TREES2[(h >>> 24) % TREES2.length];
1963
+ const flip = (h >>> 20 & 255) / 256 < TREE_FLIP_RATE2;
1964
+ const sc = TREE_SCALE_BASE2 * (1 + ((h >>> 12 & 255) / 256 - 0.5) * 2 * TREE_SCALE_JITTER2);
1965
+ const px = wx * TILE4 + TILE4 / 2 + ((h >>> 16 & 15) / 16 - 0.5) * 2 * TREE_JITTER2;
1966
+ const py = wy * TILE4 + TILE4 / 2 + ((h >>> 28 & 15) / 16 - 0.5) * 2 * TREE_JITTER2;
1967
+ const tr = new Sprite(texFrame(ctx.props, ...T.frame));
1968
+ tr.anchor.set(T.ax, T.ay);
1969
+ tr.scale.set(flip ? -sc : sc, sc);
1970
+ tr.x = px;
1971
+ tr.y = py;
1972
+ tr.zIndex = py;
1973
+ let shadow = null;
1974
+ if (ctx.propShadow) {
1975
+ shadow = new Sprite(texFrame(ctx.propShadow, ...T.shadow));
1976
+ shadow.anchor.set(0.5, 0.5);
1977
+ shadow.scale.set(sc, sc);
1978
+ shadow.x = px;
1979
+ shadow.y = py;
1980
+ }
1981
+ live.push({ sprite: tr, shadow });
1982
+ }
1983
+ }
1984
+ return { meta: f, live };
1985
+ },
1986
+ macroColor: biomeColor,
1987
+ // The ground tile [c,r] the renderer placed at world (wx,wy) — for the debug grid overlay.
1988
+ tileIndexAt(wx, wy, meta) {
1989
+ if (!meta) return null;
1990
+ const { field, M, SZ, x0, y0 } = meta;
1991
+ const fl = (x, y) => field[(y - y0 + M) * SZ + (x - x0 + M)] === 1;
1992
+ if (wx - x0 < 0 || wy - y0 < 0 || wx - x0 >= CHUNK3 || wy - y0 >= CHUNK3) return null;
1993
+ if (!fl(wx, wy)) return CORRUPT_DARK;
1994
+ const t = corruptTile(
1995
+ !fl(wx, wy - 1),
1996
+ !fl(wx + 1, wy),
1997
+ !fl(wx, wy + 1),
1998
+ !fl(wx - 1, wy),
1999
+ !fl(wx - 1, wy - 1),
2000
+ !fl(wx + 1, wy - 1),
2001
+ !fl(wx - 1, wy + 1),
2002
+ !fl(wx + 1, wy + 1)
2003
+ );
2004
+ return t || CORRUPT_LIGHT;
2005
+ }
2006
+ });
2007
+
2008
+ // ../auto-battler/src/render/biomeRegistry.js
2009
+ var BIOME_FACTORIES = {
2010
+ forgottenPlains: fpConfig,
2011
+ orc: orcConfig,
2012
+ necropolis: necropolisConfig
2013
+ };
2014
+ var BIOME_GROUNDS = {
2015
+ forgottenPlains: { fills: FP_GROUND_FILLS, fillKey: fpGroundFillKey },
2016
+ orc: { fills: ORC_GROUND_FILLS, fillKey: orcGroundFillKey },
2017
+ necropolis: { fills: NECRO_GROUND_FILLS, fillKey: necroGroundFillKey }
2018
+ };
2019
+
2020
+ // ../auto-battler/src/render/transitionAtlas.js
2021
+ var VARIANTS = 3;
2022
+ function loadImg3(url) {
2023
+ return new Promise((resolve, reject) => {
2024
+ const img = new Image();
2025
+ img.onload = () => resolve(img);
2026
+ img.onerror = reject;
2027
+ img.src = url;
2028
+ });
2029
+ }
2030
+ function tilePixels(img, c, r) {
2031
+ const cv = document.createElement("canvas");
2032
+ cv.width = TILE;
2033
+ cv.height = TILE;
2034
+ const g = cv.getContext("2d", { willReadFrequently: true });
2035
+ g.imageSmoothingEnabled = false;
2036
+ g.drawImage(img, c * TILE, r * TILE, TILE, TILE, 0, 0, TILE, TILE);
2037
+ return g.getImageData(0, 0, TILE, TILE).data;
2038
+ }
2039
+ async function buildTransitionAtlas({ grounds, ids }) {
2040
+ const imgCache = /* @__PURE__ */ new Map(), pxCache = /* @__PURE__ */ new Map();
2041
+ const getPx = async (url, tile) => {
2042
+ const k = url + "|" + tile[0] + "," + tile[1];
2043
+ if (pxCache.has(k)) return pxCache.get(k);
2044
+ let img = imgCache.get(url);
2045
+ if (!img) {
2046
+ img = await loadImg3(url);
2047
+ imgCache.set(url, img);
2048
+ }
2049
+ const p = tilePixels(img, tile[0], tile[1]);
2050
+ pxCache.set(k, p);
2051
+ return p;
2052
+ };
2053
+ const combos = [];
2054
+ for (const A of ids) for (const B of ids) {
2055
+ if (A === B) continue;
2056
+ for (const fa of grounds[A].fills) for (const fb of grounds[B].fills) combos.push({ A, B, fa, fb });
2057
+ }
2058
+ for (const c of combos) {
2059
+ c.fp = await getPx(c.fa.url, c.fa.tile);
2060
+ c.bp = await getPx(c.fb.url, c.fb.tile);
2061
+ }
2062
+ const rowOf = /* @__PURE__ */ new Map();
2063
+ combos.forEach((c, i) => rowOf.set(c.A + "|" + c.fa.key + "|" + c.B + "|" + c.fb.key, i));
2064
+ const caseCol = /* @__PURE__ */ new Map();
2065
+ STENCIL_CASES.forEach((k, i) => caseCol.set(k, i));
2066
+ const canvas = document.createElement("canvas");
2067
+ canvas.width = STENCIL_CASES.length * VARIANTS * TILE;
2068
+ canvas.height = combos.length * TILE;
2069
+ const ctx = canvas.getContext("2d");
2070
+ ctx.imageSmoothingEnabled = false;
2071
+ for (let r = 0; r < combos.length; r++) {
2072
+ const { fp, bp } = combos[r];
2073
+ for (let ci = 0; ci < STENCIL_CASES.length; ci++) {
2074
+ for (let v = 0; v < VARIANTS; v++) {
2075
+ const mask = makeStencil(STENCIL_CASES[ci], v);
2076
+ const img = ctx.createImageData(TILE, TILE);
2077
+ for (let p = 0; p < TILE * TILE; p++) {
2078
+ const src = mask[p] ? fp : bp, o = p * 4;
2079
+ img.data[o] = src[o];
2080
+ img.data[o + 1] = src[o + 1];
2081
+ img.data[o + 2] = src[o + 2];
2082
+ img.data[o + 3] = src[o + 3];
2083
+ }
2084
+ ctx.putImageData(img, (ci * VARIANTS + v) * TILE, r * TILE);
2085
+ }
2086
+ }
2087
+ }
2088
+ const tileAt = (fgId, fgKey, bgId, bgKey, caseKey, variant) => {
2089
+ const row = rowOf.get(fgId + "|" + fgKey + "|" + bgId + "|" + bgKey);
2090
+ const ci = caseCol.get(caseKey);
2091
+ if (row === void 0 || ci === void 0) return null;
2092
+ return [ci * VARIANTS + (variant % VARIANTS + VARIANTS) % VARIANTS, row];
2093
+ };
2094
+ return { canvas, tileAt, variants: VARIANTS };
2095
+ }
2096
+
2097
+ // ../auto-battler/src/render/overworld.js
2098
+ var TILE5 = 8;
2099
+ var CHUNK4 = 32;
2100
+ var MACRO_BLEND = 0.06;
2101
+ var REGION_TINT = {
2102
+ forgottenPlains: [97, 150, 55],
2103
+ // meadow green
2104
+ orc: [150, 128, 95],
2105
+ // dirt tan
2106
+ necropolis: [120, 110, 128]
2107
+ // corrupted grey-purple
2108
+ };
2109
+ var TINT_MIX = 0.4;
2110
+ var RANK = {};
2111
+ OVERWORLD_BIOMES.forEach((id, i) => {
2112
+ RANK[id] = i;
2113
+ });
2114
+ var overworldConfig = (seed, opts = {}) => {
2115
+ const region = opts.regionFn ?? biomeRegion;
2116
+ const biomes = {};
2117
+ for (const id of OVERWORLD_BIOMES) {
2118
+ const factory = BIOME_FACTORIES[id];
2119
+ if (!factory) throw new Error(`overworld: no biome config registered for "${id}"`);
2120
+ biomes[id] = factory(seed);
2121
+ }
2122
+ return {
2123
+ seed,
2124
+ tile: TILE5,
2125
+ chunk: CHUNK4,
2126
+ background: "#2b2f3a",
2127
+ bounds: opts.bounds,
2128
+ initialCamera: opts.initialCamera,
2129
+ // Each biome loads its own sheets into its own ctx; then build the cross-biome transition atlas
2130
+ // from their base grounds. Key everything for bake to route to.
2131
+ async load(api) {
2132
+ const ctxById = {};
2133
+ for (const id of OVERWORLD_BIOMES) ctxById[id] = await biomes[id].load(api);
2134
+ const atlas = await buildTransitionAtlas({ grounds: BIOME_GROUNDS, ids: OVERWORLD_BIOMES });
2135
+ const texture = api.Texture.from(atlas.canvas);
2136
+ texture.source.scaleMode = "nearest";
2137
+ return { ctxById, trans: { source: texture.source, tileAt: atlas.tileAt, variants: atlas.variants } };
2138
+ },
2139
+ // Bake a chunk: assign each tile to its (crisp) region winner, let each present biome paint its
2140
+ // region, then overpaint the 1-tile border ring with generated transition tiles so neighbouring
2141
+ // biomes blend cleanly. Fast path: a single-biome chunk = one bake call, no mask, no seams.
2142
+ bake(api) {
2143
+ const { x0, y0, seed: seed2, ctx, add } = api;
2144
+ const GW = CHUNK4 + 2;
2145
+ const winner = new Array(GW * GW);
2146
+ const widx = (lx, ly) => (ly + 1) * GW + (lx + 1);
2147
+ for (let ly = -1; ly <= CHUNK4; ly++) for (let lx = -1; lx <= CHUNK4; lx++) {
2148
+ winner[widx(lx, ly)] = region(seed2, x0 + lx, y0 + ly).id;
2149
+ }
2150
+ const at = (lx, ly) => winner[widx(lx, ly)];
2151
+ const present = /* @__PURE__ */ new Set();
2152
+ for (let ty = 0; ty < CHUNK4; ty++) for (let tx = 0; tx < CHUNK4; tx++) present.add(at(tx, ty));
2153
+ const single = present.size === 1;
2154
+ const live = [];
2155
+ const metaById = {};
2156
+ for (const id of OVERWORLD_BIOMES) {
2157
+ if (!present.has(id)) continue;
2158
+ const accept = single ? void 0 : (tx, ty) => at(tx, ty) === id;
2159
+ const res = biomes[id].bake({ ...api, ctx: ctx.ctxById[id], accept }) || {};
2160
+ if (res.live) for (const l of res.live) live.push(l);
2161
+ if (res.meta) metaById[id] = res.meta;
2162
+ }
2163
+ const trans = ctx.trans;
2164
+ if (trans && !single) {
2165
+ for (let ty = 0; ty < CHUNK4; ty++) for (let tx = 0; tx < CHUNK4; tx++) {
2166
+ const A = at(tx, ty), rankA = RANK[A];
2167
+ const below = (lx, ly) => {
2168
+ const b = at(lx, ly);
2169
+ return b !== A && RANK[b] < rankA;
2170
+ };
2171
+ const key = boundaryCase(
2172
+ below(tx, ty - 1),
2173
+ below(tx + 1, ty),
2174
+ below(tx, ty + 1),
2175
+ below(tx - 1, ty),
2176
+ below(tx - 1, ty - 1),
2177
+ below(tx + 1, ty - 1),
2178
+ below(tx - 1, ty + 1),
2179
+ below(tx + 1, ty + 1)
2180
+ );
2181
+ if (!key) continue;
2182
+ const B = dominantForeign(at, tx, ty, A, rankA);
2183
+ if (!B) continue;
2184
+ const wx = x0 + tx, wy = y0 + ty;
2185
+ const fgKey = BIOME_GROUNDS[A].fillKey(seed2, wx, wy);
2186
+ const bgKey = BIOME_GROUNDS[B].fillKey(seed2, wx, wy);
2187
+ const variant = hashU32(seed2 ^ 1957, wx, wy) % trans.variants;
2188
+ const cr = trans.tileAt(A, fgKey, B, bgKey, key, variant);
2189
+ if (cr) add(trans.source, cr[0], cr[1], tx, ty);
2190
+ }
2191
+ }
2192
+ return { live, meta: { winner, GW, metaById } };
2193
+ },
2194
+ // Zoomed-out world map: a flat region tint (so lands stay legible at any zoom) modulated by a
2195
+ // little of the biome's internal terrain colour, cross-fading into the runner-up region across
2196
+ // a thin border band so regions read as soft-edged lands, not hard cells.
2197
+ macroColor(seed2, tx, ty) {
2198
+ const { id, id2, edge } = region(seed2, tx, ty);
2199
+ let tint = REGION_TINT[id];
2200
+ if (edge < MACRO_BLEND && id !== id2) tint = lerp3(tint, REGION_TINT[id2], 0.5 * (1 - edge / MACRO_BLEND));
2201
+ return lerp3(tint, biomes[id].macroColor(seed2, tx, ty), TINT_MIX);
2202
+ },
2203
+ // Which biome owns a world tile (no chunk meta needed) — for callers that key behaviour to the
2204
+ // land the player is on (free-roam enemy rosters, per-biome walkability).
2205
+ biomeAt(seed2, wx, wy) {
2206
+ return region(seed2, wx, wy).id;
2207
+ },
2208
+ // Debug grid overlay: dispatch to whichever biome owns the tile, with that biome's chunk meta.
2209
+ tileIndexAt(wx, wy, meta) {
2210
+ if (!meta) return null;
2211
+ const tx = (wx % CHUNK4 + CHUNK4) % CHUNK4, ty = (wy % CHUNK4 + CHUNK4) % CHUNK4;
2212
+ const id = meta.winner[(ty + 1) * meta.GW + (tx + 1)];
2213
+ const bm = meta.metaById[id];
2214
+ return bm ? biomes[id].tileIndexAt(wx, wy, bm) : null;
2215
+ }
2216
+ };
2217
+ };
2218
+ function dominantForeign(at, tx, ty, A, rankA) {
2219
+ const tally = /* @__PURE__ */ new Map();
2220
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
2221
+ if (!dx && !dy) continue;
2222
+ const b = at(tx + dx, ty + dy);
2223
+ if (b !== A && RANK[b] < rankA) tally.set(b, (tally.get(b) || 0) + 1);
2224
+ }
2225
+ let best = null, bestN = 0;
2226
+ for (const [b, n] of tally) if (n > bestN) {
2227
+ bestN = n;
2228
+ best = b;
2229
+ }
2230
+ return best;
2231
+ }
2232
+ var GAME_W = 510;
2233
+ var GAME_H = 340;
2234
+ var BAND_IDS = ["forgottenPlains", "orc", "necropolis"];
2235
+ var BAND_WARP_SCALE = 0.014;
2236
+ var BAND_WARP_AMP = 26;
2237
+ var BAND_EDGE_TILES = 50;
2238
+ var bandSub = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
2239
+ function bandedRegion(W) {
2240
+ const b1 = W / 3, b2 = 2 * W / 3;
2241
+ return (seed, x, y) => {
2242
+ const wx = x + BAND_WARP_AMP * (fbm(bandSub(seed, 1441), x * BAND_WARP_SCALE, y * BAND_WARP_SCALE) - 0.5);
2243
+ let i, j, d;
2244
+ if (wx < b1) {
2245
+ i = 0;
2246
+ j = 1;
2247
+ d = b1 - wx;
2248
+ } else if (wx >= b2) {
2249
+ i = 2;
2250
+ j = 1;
2251
+ d = wx - b2;
2252
+ } else {
2253
+ i = 1;
2254
+ const dl = wx - b1, dr = b2 - wx;
2255
+ if (dl < dr) {
2256
+ j = 0;
2257
+ d = dl;
2258
+ } else {
2259
+ j = 2;
2260
+ d = dr;
2261
+ }
2262
+ }
2263
+ const edge = Math.max(0, Math.min(1, d / BAND_EDGE_TILES));
2264
+ return { id: BAND_IDS[i], id2: BAND_IDS[j], edge };
2265
+ };
2266
+ }
2267
+ function createGameOverworldMap(pixi, host, opts = {}) {
2268
+ const config = overworldConfig(opts.seed ?? 1, {
2269
+ regionFn: bandedRegion(GAME_W),
2270
+ bounds: { x0: 0, y0: 0, x1: GAME_W, y1: GAME_H },
2271
+ // Start the view near the left (Forgotten Plains) edge, vertically centred.
2272
+ initialCamera: { x: 64 * TILE5, y: GAME_H / 2 * TILE5 }
2273
+ });
2274
+ return createChunkedMap(pixi, host, { ...config, keyboardPan: opts.keyboardPan });
2275
  }
2276
 
2277
  // ../auto-battler/src/engine/skills.js
 
3216
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
3217
  const floats = [];
3218
  const sheetsById = {};
3219
+ async function loadSheets(id, def) {
3220
  if (!def?.idle) {
3221
  sheetsById[id] = null;
3222
+ return;
3223
  }
3224
  try {
3225
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
 
3245
  sheetsById[id] = null;
3246
  }
3247
  }
3248
+ for (const [id, def] of Object.entries(defsById)) await loadSheets(id, def);
3249
  const skillIcons = {};
3250
  for (const s of CB_SKILLS) {
3251
  try {
 
3281
  } catch {
3282
  }
3283
  const skillPlay = {};
3284
+ async function buildSkillPlay(id, def) {
3285
  const cell = sheetsById[id]?.cell;
3286
  const map = {};
3287
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
 
3306
  }
3307
  skillPlay[id] = map;
3308
  }
3309
+ for (const [id, def] of Object.entries(defsById)) await buildSkillPlay(id, def);
3310
  const view = {};
3311
+ function buildView(id, def) {
3312
  const sh = sheetsById[id];
3313
  const c = new Container();
3314
  let sp;
 
3357
  units.addChild(c);
3358
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
3359
  }
3360
+ for (const [id, def] of Object.entries(defsById)) buildView(id, def);
3361
+ async function addActor(id, def) {
3362
+ if (view[id]) return view[id];
3363
+ defsById[id] = def;
3364
+ await loadSheets(id, def);
3365
+ await buildSkillPlay(id, def);
3366
+ buildView(id, def);
3367
+ return view[id];
3368
+ }
3369
+ function removeActor2(id) {
3370
+ const v = view[id];
3371
+ if (!v) return;
3372
+ units.removeChild(v.container);
3373
+ v.container.destroy({ children: true });
3374
+ delete view[id];
3375
+ delete sheetsById[id];
3376
+ delete skillPlay[id];
3377
+ delete defsById[id];
3378
+ }
3379
  function setLoop(v, mode, facing) {
3380
  if (v.mode === mode && v.facing === facing) return;
3381
  v.mode = mode;
 
3751
  floats,
3752
  actorOf,
3753
  skillPlay,
3754
+ addActor,
3755
+ removeActor: removeActor2,
3756
  setLoop,
3757
  playOnce,
3758
  resume,
 
3887
  // optional: idle until a foe is within this distance (else always engage)
3888
  };
3889
  }
3890
+ function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null, field = null } = {}) {
3891
  const actors = [];
3892
  players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
3893
  enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
3894
+ return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {}, world, field: field || FIELD };
3895
+ }
3896
+ function spawnActor(b, unit, team, id) {
3897
+ const a = makeActor(unit, team, id, 0);
3898
+ a.attackTimer = a.weapon.interval;
3899
+ b.actors.push(a);
3900
+ return a;
3901
+ }
3902
+ function removeActor(b, id) {
3903
+ const i = b.actors.findIndex((a) => a.id === id);
3904
+ if (i >= 0) b.actors.splice(i, 1);
3905
  }
3906
  function setInput(b, id, cmd) {
3907
  if (!b.input) b.input = {};
 
3916
  var MAX_BATTLE_T = 90;
3917
  var COLLISION_Y_WEIGHT = 3.2;
3918
  var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS;
3919
+ var edgeGap = (a, t) => dist2(a, t) - (a.radius || 0) - (t.radius || 0);
3920
  var MELEE_REACH = 2;
3921
  var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH;
3922
  var SPELL_RANGE = 900;
3923
  var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]);
3924
+ var dist2 = (a, e) => Math.hypot(a.x - e.x, a.y - e.y);
3925
  var hasCond = (a, type) => a.conds.some((c) => c.type === type);
3926
  var isKd = (b, a) => a.kd > b.t;
3927
  var gainAdr = (a, n) => {
 
3932
  function nearestFoe(b, a) {
3933
  let best = null, bd = Infinity;
3934
  for (const x of livingFoes(b, a)) {
3935
+ const d = dist2(a, x);
3936
  if (d < bd) {
3937
  bd = d;
3938
  best = x;
 
3952
  }
3953
  return best;
3954
  }
3955
+ var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist2(x, tgt) <= ADJACENT_GW);
3956
  var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m });
3957
  var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t);
3958
  var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t);
 
4194
  }
4195
  }
4196
  function shadowStep(b, a, tgt) {
4197
+ const f = b.field || FIELD;
4198
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
4199
+ a.x = Math.max(0, Math.min(f.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
4200
+ a.y = Math.max(0, Math.min(f.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
4201
  }
4202
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
4203
+ const base = dist2(src, tgt) / 900;
4204
  for (let i = 0; i < n; i++) {
4205
  b.projectiles.push({
4206
  srcId: src.id,
 
4277
  }
4278
  }
4279
  if (a.role === "ranged") {
4280
+ const flight = dist2(a, enemy) / (a.weapon.projSpeed || 800);
4281
  b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect });
4282
  log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id });
4283
  } else {
 
4390
  if (s.cost?.energy && a.energy < s.cost.energy) return false;
4391
  if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false;
4392
  if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false;
4393
+ if (!isAttack(s) && !isSupport(s) && dist2(a, foe) > SPELL_RANGE) return false;
4394
  for (const r of s.requires || []) {
4395
  if (r === "on_hit") continue;
4396
  if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false;
 
4424
  return null;
4425
  }
4426
  function moveActor(b, a, enemy, dt) {
4427
+ const d = dist2(a, enemy);
4428
  let toward = 0;
4429
  if (a.role === "ranged") {
4430
  if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1;
 
4498
  }
4499
  var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
4500
  function stepMove(b, a, vx, vy, dt) {
4501
+ const f = b.field || FIELD;
4502
  const w = b.world && b.world.walkable;
4503
+ let nx = clampField(a.x + vx * dt, a.radius, f.w);
4504
+ let ny = clampField(a.y + vy * dt, a.radius, f.h);
4505
  if (w) {
4506
  if (!w(nx, a.y)) nx = a.x;
4507
  if (!w(nx, ny)) ny = a.y;
 
4510
  a.y = ny;
4511
  }
4512
  function resolveOverlaps(b) {
4513
+ const f = b.field || FIELD;
4514
  const live = b.actors.filter((a) => a.alive);
4515
  for (let it = 0; it < DEOVERLAP_ITERS; it++) {
4516
  for (let i = 0; i < live.length; i++) {
 
4526
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
4527
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
4528
  const yPush = uy / COLLISION_Y_WEIGHT;
4529
+ a.x = clampField(a.x - ux * push * aShare, a.radius, f.w);
4530
+ a.y = clampField(a.y - yPush * push * aShare, a.radius, f.h);
4531
+ o.x = clampField(o.x + ux * push * oShare, o.radius, f.w);
4532
+ o.y = clampField(o.y + yPush * push * oShare, o.radius, f.h);
4533
  }
4534
  }
4535
  }
 
4630
  if (!a.alive || b.over) continue;
4631
  const enemy = nearestFoe(b, a);
4632
  if (!enemy && a.control !== "player") continue;
4633
+ if (a.aggroRadius != null && a.control !== "player" && dist2(a, enemy) > a.aggroRadius) {
4634
  a.moving = false;
4635
  continue;
4636
  }
 
4689
  }
4690
 
4691
  // ../auto-battler/src/sim/walkable.js
4692
+ function makeRoamWalkable(seed, biomeAt) {
4693
+ return (wx, wy) => {
4694
+ const b = biomeAt ? biomeAt(wx, wy) : "forgottenPlains";
4695
+ if (b === "necropolis") return !isRiver2(seed, wx, wy) && !isRaised2(seed, wx, wy);
4696
+ if (b === "orc") return true;
4697
+ return !isRiver(seed, wx, wy) && !isRaised(seed, wx, wy);
4698
+ };
4699
  }
4700
 
4701
  // ../auto-battler/src/render/comboBattler.js
4702
+ var TILE6 = 8;
4703
  var SPRITE_TILES = 1.9;
4704
  var STEP = 0.05;
4705
+ var SPAWN_TILES = 22;
4706
+ var SPAWN_JITTER = 6;
4707
+ var DESPAWN_TILES = 52;
4708
+ var TARGET_FOES = 8;
4709
+ var MAX_FOES = 16;
4710
+ var SPAWN_INTERVAL = 0.4;
4711
+ var DEATH_LINGER = 1.6;
4712
  async function contentHeight(url) {
4713
  try {
4714
  const img = await new Promise((res, rej) => {
 
4738
  const { Graphics, Container } = pixi;
4739
  const seed = (opts.seed ?? 1) >>> 0;
4740
  const G = opts.groundScale ?? 0.3;
4741
+ const ARENA = { ox: 0, oy: 0 };
4742
+ const fieldToWorld = (fx, fy) => ({ x: ARENA.ox + fx * G, y: ARENA.oy + fy * G });
4743
+ const worldToField = (wx, wy) => ({ x: (wx - ARENA.ox) / G, y: (wy - ARENA.oy) / G });
4744
+ const map = createGameOverworldMap(pixi, host, { seed, keyboardPan: false });
4745
+ const roamWalkable = makeRoamWalkable(seed, (wx, wy) => map.biomeAt(wx, wy));
4746
+ const world = { walkable: (fx, fy) => {
4747
+ const w = fieldToWorld(fx, fy);
4748
+ return roamWalkable(Math.round(w.x / TILE6), Math.round(w.y / TILE6));
4749
+ } };
4750
+ const rosters = opts.rosters || (() => {
4751
+ const e = opts.enemies || [];
4752
+ return { forgottenPlains: e, orc: e, necropolis: e };
4753
+ })();
4754
+ let battle = null, R = null, combatRoot = null, rings = null, markers = null, spawnFn = null, dead = false;
4755
  const depthScale = { v: 2 };
4756
  let offTick = null, keyHandlers = null, tapHandlers = null, controlsEl = null, alive = true;
4757
  const keys = { x: 0, y: 0 };
 
4762
  const listeners = /* @__PURE__ */ new Set();
4763
  const pa = () => battle?.actors.find((a) => a.id === "P0") || null;
4764
  const ea = () => battle?.actors.filter((a) => a.team === "enemy") || [];
4765
+ const wtile = (fx, fy) => {
4766
+ const w = fieldToWorld(fx, fy);
4767
+ return { wx: Math.round(w.x / TILE6), wy: Math.round(w.y / TILE6) };
4768
+ };
4769
  const snap = () => ({
4770
+ player: pa() ? { x: pa().x, y: pa().y, hp: pa().hp, ...wtile(pa().x, pa().y) } : null,
4771
+ enemies: ea().map((a) => ({ x: a.x, y: a.y, hp: Math.round(a.hp), alive: a.alive, ...wtile(a.x, a.y) })),
4772
+ over: dead || !!battle?.over
4773
  });
4774
  const emit = () => listeners.forEach((fn) => fn(snap()));
4775
  const KEYMAP = { w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], arrowup: [0, -1], arrowdown: [0, 1], arrowleft: [-1, 0], arrowright: [1, 0] };
 
4830
  downAt = null;
4831
  if (moved > 8 || elapsed > 500) return;
4832
  const r = canvas.getBoundingClientRect(), w = map.screenToWorld(e.clientX - r.left, e.clientY - r.top);
4833
+ moveTarget = worldToField(w.x, w.y);
4834
  };
4835
  canvas.addEventListener("pointerdown", onDown);
4836
  window.addEventListener("pointerup", onUp);
 
4863
  host.appendChild(bar);
4864
  controlsEl = bar;
4865
  }
4866
+ const dist3 = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
4867
  const nearestFoeDist = () => {
4868
  const p = pa();
4869
  if (!p) return Infinity;
4870
  let m = Infinity;
4871
+ for (const e of ea()) if (e.alive) m = Math.min(m, dist3(p, e));
4872
  return m;
4873
  };
4874
  function tick(ticker) {
 
4899
  step(battle, STEP);
4900
  acc.t -= STEP;
4901
  }
4902
+ if (!dead && p && !p.alive) {
4903
+ dead = true;
4904
+ }
4905
+ spawnFn && spawnFn(dt);
4906
  R.syncActors(battle, dtMS, battle.t);
4907
  R.updateFloats(dtMS);
4908
  R.drawProjectiles(battle);
4909
  R.processLog(battle, cursor);
4910
  drawRings();
4911
+ drawMarkers();
4912
  emit();
4913
  }
4914
  function drawRings() {
 
4917
  if (!p) return;
4918
  rings.ellipse(p.x, p.y, p.weapon.range, p.weapon.range * 0.6).stroke({ width: 1.5 / G, color: 16777215, alpha: 0.25 });
4919
  }
4920
+ function drawMarkers() {
4921
+ if (!markers) return;
4922
+ markers.clear();
4923
+ const a = 1 - Math.max(0, Math.min(1, (map.getCamera().zoom - 0.5) / (0.9 - 0.5)));
4924
+ if (a <= 0) return;
4925
+ const dot = (fx, fy, color) => {
4926
+ const w = fieldToWorld(fx, fy), s = map.worldToScreen(w.x, w.y);
4927
+ markers.circle(s.x, s.y, 4.5).fill({ color, alpha: a }).stroke({ width: 1.5, color: 1053723, alpha: a });
4928
+ };
4929
+ for (const e of ea()) if (e.alive) dot(e.x, e.y, 16726832);
4930
+ const p = pa();
4931
+ if (p) dot(p.x, p.y, 16766474);
4932
+ }
4933
  const ready = (async () => {
4934
  await map.ready;
4935
  if (!alive) return;
4936
  const cam = map.getCamera();
4937
+ const bnds = map.getBounds();
4938
+ ARENA.ox = bnds.x0;
4939
+ ARENA.oy = bnds.y0;
4940
+ const field = { w: (bnds.x1 - bnds.x0) / G, h: (bnds.y1 - bnds.y0) / G };
4941
+ const FSTEP = TILE6 / G;
4942
+ const snapField = (fx2, fy) => {
4943
+ for (let r = 0; r <= 24; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
4944
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
4945
+ const x = fx2 + dx * FSTEP, y = fy + dy * FSTEP;
4946
+ if (x >= 0 && x <= field.w && y >= 0 && y <= field.h && world.walkable(x, y)) return { x, y };
4947
+ }
4948
+ return { x: Math.max(0, Math.min(field.w, fx2)), y: Math.max(0, Math.min(field.h, fy)) };
4949
  };
 
 
4950
  const pProf = opts.player?.unit?.profession;
4951
  const pSkills = opts.player?.unit?.skills?.length ? opts.player.unit.skills : CB_SKILLS.filter((s) => s.profession === pProf).slice(0, 3).map((s) => s.id);
4952
  const players = [{ ...opts.player?.unit || {}, name: opts.player?.name || "Hero", control: "player", skills: pSkills }];
4953
+ battle = makeTeamBattle({ seed, players, enemies: [], sandbox: true, freeCast: true, world, field });
4954
+ const start = worldToField(cam.x, cam.y);
4955
+ const p0 = snapField(start.x, start.y);
4956
  const P = pa();
4957
  if (P) {
4958
+ P.x = p0.x;
4959
+ P.y = p0.y;
4960
+ }
 
 
 
 
 
 
 
4961
  const defsById = {};
4962
  const sd = (o) => ({ idle: o?.idle, walk: o?.walk, attack: o?.attack, dmg: o?.dmg, die: o?.die });
4963
  defsById.P0 = { name: players[0].name, profession: players[0].profession, ...sd(opts.player?.sheets) };
 
 
 
4964
  combatRoot = new Container();
4965
  combatRoot.scale.set(G);
4966
+ combatRoot.position.set(ARENA.ox, ARENA.oy);
4967
  rings = new Graphics();
4968
  combatRoot.addChild(rings);
4969
  const units = new Container();
 
4972
  const proj = new Graphics();
4973
  combatRoot.addChild(units, proj, fx);
4974
  map.getEntityLayer().addChild(combatRoot);
4975
+ markers = new Graphics();
4976
+ map.getApp().stage.addChild(markers);
4977
  const ch = await contentHeight(opts.player?.sheets?.idle || opts.player?.sheets?.walk);
4978
+ depthScale.v = SPRITE_TILES * TILE6 / (ch * G);
4979
  R = await createCombatRenderer({ pixi, defsById, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
4980
  if (!alive) return;
4981
  for (const a of battle.actors) a.attackTimer = 0;
4982
+ let foeN = 0;
4983
+ const deadAt = /* @__PURE__ */ new Map();
4984
+ const spawnAcc = { t: SPAWN_INTERVAL };
4985
+ const rosterAt = (fx2, fy) => {
4986
+ const w = fieldToWorld(fx2, fy);
4987
+ const b = map.biomeAt(Math.round(w.x / TILE6), Math.round(w.y / TILE6)) || "forgottenPlains";
4988
+ const r = rosters[b];
4989
+ return r && r.length ? r : rosters.forgottenPlains || [];
4990
+ };
4991
+ const addFoe = async (entry, pos) => {
4992
+ const id = "R" + foeN++;
4993
+ const a = spawnActor(battle, { ...entry.unit || {}, name: entry.name }, "enemy", id);
4994
+ a.x = pos.x;
4995
+ a.y = pos.y;
4996
+ if (alive) await R.addActor(id, { name: entry.name, ...sd(entry.sheets) });
4997
+ };
4998
+ spawnFn = (dt) => {
4999
+ if (dead || !battle) return;
5000
+ const p = pa();
5001
+ if (!p) return;
5002
+ for (const a of ea()) {
5003
+ if (!a.alive) {
5004
+ if (!deadAt.has(a.id)) deadAt.set(a.id, battle.t);
5005
+ else if (battle.t - deadAt.get(a.id) > DEATH_LINGER) {
5006
+ R.removeActor(a.id);
5007
+ removeActor(battle, a.id);
5008
+ deadAt.delete(a.id);
5009
+ }
5010
+ } else if (dist3(a, p) > DESPAWN_TILES * FSTEP) {
5011
+ R.removeActor(a.id);
5012
+ removeActor(battle, a.id);
5013
+ }
5014
+ }
5015
+ spawnAcc.t += dt;
5016
+ if (spawnAcc.t < SPAWN_INTERVAL) return;
5017
+ spawnAcc.t = 0;
5018
+ const need = Math.min(TARGET_FOES, MAX_FOES) - ea().filter((a) => a.alive).length;
5019
+ for (let i = 0; i < need; i++) {
5020
+ const ang = Math.random() * Math.PI * 2;
5021
+ const rr = (SPAWN_TILES + (Math.random() * 2 - 1) * SPAWN_JITTER) * FSTEP;
5022
+ const pos = snapField(p.x + Math.cos(ang) * rr, p.y + Math.sin(ang) * rr);
5023
+ const roster = rosterAt(pos.x, pos.y);
5024
+ if (!roster.length) continue;
5025
+ addFoe(roster[Math.random() * roster.length | 0], pos);
5026
+ }
5027
+ };
5028
  bindKeys();
5029
  bindTap();
5030
  buildControls();
 
5062
  controlsEl?.remove();
5063
  } catch {
5064
  }
5065
+ try {
5066
+ markers?.destroy();
5067
+ } catch {
5068
+ }
5069
  try {
5070
  map.destroy();
5071
  } catch {
 
5075
  combatRoot = null;
5076
  listeners.clear();
5077
  }
5078
+ const ctrl = { ready, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
5079
  if (typeof window !== "undefined") {
5080
  window.__comboSnap = () => ctrl.getSnapshot();
5081
  window.__combo = ctrl;
web/enemiesSandbox.js CHANGED
@@ -855,11 +855,11 @@ function makeActor(unit, team, id, slot) {
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 = {};
@@ -1152,9 +1152,10 @@ function applyEffect(b, src, tgt, e, delivery = "spell", s = null) {
1152
  }
1153
  }
1154
  function shadowStep(b, a, tgt) {
 
1155
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
1156
- a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
1157
- a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
1158
  }
1159
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
1160
  const base = dist(src, tgt) / 900;
@@ -1455,9 +1456,10 @@ function timeToHit(px, py, rvx, rvy, R) {
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;
@@ -1466,6 +1468,7 @@ function stepMove(b, a, vx, vy, dt) {
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++) {
1471
  for (let i = 0; i < live.length; i++) {
@@ -1481,10 +1484,10 @@ function resolveOverlaps(b) {
1481
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
1482
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
1483
  const yPush = uy / COLLISION_Y_WEIGHT;
1484
- a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w);
1485
- a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h);
1486
- o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w);
1487
- o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h);
1488
  }
1489
  }
1490
  }
@@ -2026,10 +2029,10 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2026
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
2027
  const floats = [];
2028
  const sheetsById = {};
2029
- for (const [id, def] of Object.entries(defsById)) {
2030
  if (!def?.idle) {
2031
  sheetsById[id] = null;
2032
- continue;
2033
  }
2034
  try {
2035
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
@@ -2055,6 +2058,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2055
  sheetsById[id] = null;
2056
  }
2057
  }
 
2058
  const skillIcons = {};
2059
  for (const s of CB_SKILLS) {
2060
  try {
@@ -2090,7 +2094,7 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2090
  } catch {
2091
  }
2092
  const skillPlay = {};
2093
- for (const [id, def] of Object.entries(defsById)) {
2094
  const cell = sheetsById[id]?.cell;
2095
  const map = {};
2096
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
@@ -2115,8 +2119,9 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2115
  }
2116
  skillPlay[id] = map;
2117
  }
 
2118
  const view = {};
2119
- for (const [id, def] of Object.entries(defsById)) {
2120
  const sh = sheetsById[id];
2121
  const c = new Container();
2122
  let sp;
@@ -2165,6 +2170,25 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2165
  units.addChild(c);
2166
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
2167
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2168
  function setLoop(v, mode, facing) {
2169
  if (v.mode === mode && v.facing === facing) return;
2170
  v.mode = mode;
@@ -2540,6 +2564,8 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
2540
  floats,
2541
  actorOf,
2542
  skillPlay,
 
 
2543
  setLoop,
2544
  playOnce,
2545
  resume,
 
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, field = 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, field: field || FIELD };
863
  }
864
  function setInput(b, id, cmd) {
865
  if (!b.input) b.input = {};
 
1152
  }
1153
  }
1154
  function shadowStep(b, a, tgt) {
1155
+ const f = b.field || FIELD;
1156
  const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
1157
+ a.x = Math.max(0, Math.min(f.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
1158
+ a.y = Math.max(0, Math.min(f.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
1159
  }
1160
  function fireSpellProjectiles(b, src, tgt, amt, e, n) {
1161
  const base = dist(src, tgt) / 900;
 
1456
  }
1457
  var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
1458
  function stepMove(b, a, vx, vy, dt) {
1459
+ const f = b.field || FIELD;
1460
  const w = b.world && b.world.walkable;
1461
+ let nx = clampField(a.x + vx * dt, a.radius, f.w);
1462
+ let ny = clampField(a.y + vy * dt, a.radius, f.h);
1463
  if (w) {
1464
  if (!w(nx, a.y)) nx = a.x;
1465
  if (!w(nx, ny)) ny = a.y;
 
1468
  a.y = ny;
1469
  }
1470
  function resolveOverlaps(b) {
1471
+ const f = b.field || FIELD;
1472
  const live = b.actors.filter((a) => a.alive);
1473
  for (let it = 0; it < DEOVERLAP_ITERS; it++) {
1474
  for (let i = 0; i < live.length; i++) {
 
1484
  const aShare = aFix ? 0 : oFix ? 1 : 0.5;
1485
  const oShare = oFix ? 0 : aFix ? 1 : 0.5;
1486
  const yPush = uy / COLLISION_Y_WEIGHT;
1487
+ a.x = clampField(a.x - ux * push * aShare, a.radius, f.w);
1488
+ a.y = clampField(a.y - yPush * push * aShare, a.radius, f.h);
1489
+ o.x = clampField(o.x + ux * push * oShare, o.radius, f.w);
1490
+ o.y = clampField(o.y + yPush * push * oShare, o.radius, f.h);
1491
  }
1492
  }
1493
  }
 
2029
  const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id);
2030
  const floats = [];
2031
  const sheetsById = {};
2032
+ async function loadSheets(id, def) {
2033
  if (!def?.idle) {
2034
  sheetsById[id] = null;
2035
+ return;
2036
  }
2037
  try {
2038
  const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null;
 
2058
  sheetsById[id] = null;
2059
  }
2060
  }
2061
+ for (const [id, def] of Object.entries(defsById)) await loadSheets(id, def);
2062
  const skillIcons = {};
2063
  for (const s of CB_SKILLS) {
2064
  try {
 
2094
  } catch {
2095
  }
2096
  const skillPlay = {};
2097
+ async function buildSkillPlay(id, def) {
2098
  const cell = sheetsById[id]?.cell;
2099
  const map = {};
2100
  for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) {
 
2119
  }
2120
  skillPlay[id] = map;
2121
  }
2122
+ for (const [id, def] of Object.entries(defsById)) await buildSkillPlay(id, def);
2123
  const view = {};
2124
+ function buildView(id, def) {
2125
  const sh = sheetsById[id];
2126
  const c = new Container();
2127
  let sp;
 
2170
  units.addChild(c);
2171
  view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false };
2172
  }
2173
+ for (const [id, def] of Object.entries(defsById)) buildView(id, def);
2174
+ async function addActor(id, def) {
2175
+ if (view[id]) return view[id];
2176
+ defsById[id] = def;
2177
+ await loadSheets(id, def);
2178
+ await buildSkillPlay(id, def);
2179
+ buildView(id, def);
2180
+ return view[id];
2181
+ }
2182
+ function removeActor(id) {
2183
+ const v = view[id];
2184
+ if (!v) return;
2185
+ units.removeChild(v.container);
2186
+ v.container.destroy({ children: true });
2187
+ delete view[id];
2188
+ delete sheetsById[id];
2189
+ delete skillPlay[id];
2190
+ delete defsById[id];
2191
+ }
2192
  function setLoop(v, mode, facing) {
2193
  if (v.mode === mode && v.facing === facing) return;
2194
  v.mode = mode;
 
2564
  floats,
2565
  actorOf,
2566
  skillPlay,
2567
+ addActor,
2568
+ removeActor,
2569
  setLoop,
2570
  playOnce,
2571
  resume,
web/mapSandbox.js CHANGED
@@ -16,6 +16,16 @@ function createChunkedMap(pixi, host, config) {
16
  const MACRO_LEVEL_MAX = config.macroLevelMax ?? 6;
17
  const DETAIL_BUDGET = config.detailBudget ?? 1;
18
  const MACRO_BUDGET = config.macroBudget ?? 8;
 
 
 
 
 
 
 
 
 
 
19
  let app = null, root = null, genRoot = null, macroRoot = null, shadowLayer = null, propLayer = null;
20
  let ctx = null;
21
  let alive = true;
@@ -23,7 +33,7 @@ function createChunkedMap(pixi, host, config) {
23
  let seed = config.seed ?? 1;
24
  let zoom = Z_DEFAULT;
25
  let cameraDirty = true, genPending = false, lastEmitTile = null;
26
- const camera = { x: CHUNKPX / 2, y: CHUNKPX / 2 };
27
  const chunks = /* @__PURE__ */ new Map();
28
  const macroChunks = /* @__PURE__ */ new Map();
29
  const fading = /* @__PURE__ */ new Set();
@@ -104,8 +114,21 @@ function createChunkedMap(pixi, host, config) {
104
  sprite.width = sprite.height = MCHUNK * step * TILE6;
105
  return { sprite, tex: t };
106
  }
 
 
 
 
 
 
 
 
 
 
 
107
  function reconcile() {
108
  if (!genRoot || !ctx || !app) return;
 
 
109
  const sx = Math.round(app.screen.width / 2 - camera.x * zoom);
110
  const sy = Math.round(app.screen.height / 2 - camera.y * zoom);
111
  for (const L of [macroRoot, genRoot, shadowLayer, propLayer]) {
@@ -142,8 +165,14 @@ function createChunkedMap(pixi, host, config) {
142
  }
143
  function reconcileDetail() {
144
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
145
- const c0 = Math.floor((camera.x - halfW) / CHUNKPX) - 1, c1 = Math.floor((camera.x + halfW) / CHUNKPX) + 1;
146
- const r0 = Math.floor((camera.y - halfH) / CHUNKPX) - 1, r1 = Math.floor((camera.y + halfH) / CHUNKPX) + 1;
 
 
 
 
 
 
147
  for (const [key, ch] of chunks) {
148
  const [cx, cy] = key.split(",").map(Number);
149
  if (cx < c0 - 1 || cx > c1 + 1 || cy < r0 - 1 || cy > r1 + 1) evictChunk(key, ch);
@@ -171,8 +200,15 @@ function createChunkedMap(pixi, host, config) {
171
  function reconcileMacro(L) {
172
  const px = MCHUNK * (1 << L) * TILE6;
173
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
174
- const c0 = Math.floor((camera.x - halfW) / px) - 1, c1 = Math.floor((camera.x + halfW) / px) + 1;
175
- const r0 = Math.floor((camera.y - halfH) / px) - 1, r1 = Math.floor((camera.y + halfH) / px) + 1;
 
 
 
 
 
 
 
176
  for (const [key, mc] of macroChunks) {
177
  const [ml, mx, my] = key.split(",").map(Number);
178
  if (ml !== L || mx < c0 - 1 || mx > c1 + 1 || my < r0 - 1 || my > r1 + 1) {
@@ -209,7 +245,7 @@ function createChunkedMap(pixi, host, config) {
209
  }
210
  function zoomAt(factor, lx, ly) {
211
  if (!app) return;
212
- const nz = Math.min(Z_MAX, Math.max(Z_MIN, zoom * factor));
213
  if (nz === zoom) return;
214
  const sw = app.screen.width, sh = app.screen.height;
215
  const wx = camera.x + (lx - sw / 2) / zoom, wy = camera.y + (ly - sh / 2) / zoom;
@@ -400,6 +436,12 @@ function createChunkedMap(pixi, host, config) {
400
  if (!ch) return null;
401
  return config.tileIndexAt(wx, wy, ch.meta);
402
  }
 
 
 
 
 
 
403
  function setEnabled(v) {
404
  enabled = v;
405
  if (v) cameraDirty = true;
@@ -448,7 +490,7 @@ function createChunkedMap(pixi, host, config) {
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
@@ -2107,7 +2149,8 @@ var RANK = {};
2107
  OVERWORLD_BIOMES.forEach((id, i) => {
2108
  RANK[id] = i;
2109
  });
2110
- var overworldConfig = (seed) => {
 
2111
  const biomes = {};
2112
  for (const id of OVERWORLD_BIOMES) {
2113
  const factory = BIOME_FACTORIES[id];
@@ -2119,6 +2162,8 @@ var overworldConfig = (seed) => {
2119
  tile: TILE5,
2120
  chunk: CHUNK4,
2121
  background: "#2b2f3a",
 
 
2122
  // Each biome loads its own sheets into its own ctx; then build the cross-biome transition atlas
2123
  // from their base grounds. Key everything for bake to route to.
2124
  async load(api) {
@@ -2138,7 +2183,7 @@ var overworldConfig = (seed) => {
2138
  const winner = new Array(GW * GW);
2139
  const widx = (lx, ly) => (ly + 1) * GW + (lx + 1);
2140
  for (let ly = -1; ly <= CHUNK4; ly++) for (let lx = -1; lx <= CHUNK4; lx++) {
2141
- winner[widx(lx, ly)] = biomeRegion(seed2, x0 + lx, y0 + ly).id;
2142
  }
2143
  const at = (lx, ly) => winner[widx(lx, ly)];
2144
  const present = /* @__PURE__ */ new Set();
@@ -2188,11 +2233,16 @@ var overworldConfig = (seed) => {
2188
  // little of the biome's internal terrain colour, cross-fading into the runner-up region across
2189
  // a thin border band so regions read as soft-edged lands, not hard cells.
2190
  macroColor(seed2, tx, ty) {
2191
- const { id, id2, edge } = biomeRegion(seed2, tx, ty);
2192
  let tint = REGION_TINT[id];
2193
  if (edge < MACRO_BLEND && id !== id2) tint = lerp3(tint, REGION_TINT[id2], 0.5 * (1 - edge / MACRO_BLEND));
2194
  return lerp3(tint, biomes[id].macroColor(seed2, tx, ty), TINT_MIX);
2195
  },
 
 
 
 
 
2196
  // Debug grid overlay: dispatch to whichever biome owns the tile, with that biome's chunk meta.
2197
  tileIndexAt(wx, wy, meta) {
2198
  if (!meta) return null;
 
16
  const MACRO_LEVEL_MAX = config.macroLevelMax ?? 6;
17
  const DETAIL_BUDGET = config.detailBudget ?? 1;
18
  const MACRO_BUDGET = config.macroBudget ?? 8;
19
+ const bounds = config.bounds ? {
20
+ x0: config.bounds.x0 * TILE6,
21
+ y0: config.bounds.y0 * TILE6,
22
+ x1: config.bounds.x1 * TILE6,
23
+ y1: config.bounds.y1 * TILE6,
24
+ tx0: config.bounds.x0,
25
+ ty0: config.bounds.y0,
26
+ tx1: config.bounds.x1,
27
+ ty1: config.bounds.y1
28
+ } : null;
29
  let app = null, root = null, genRoot = null, macroRoot = null, shadowLayer = null, propLayer = null;
30
  let ctx = null;
31
  let alive = true;
 
33
  let seed = config.seed ?? 1;
34
  let zoom = Z_DEFAULT;
35
  let cameraDirty = true, genPending = false, lastEmitTile = null;
36
+ const camera = { x: config.initialCamera?.x ?? CHUNKPX / 2, y: config.initialCamera?.y ?? CHUNKPX / 2 };
37
  const chunks = /* @__PURE__ */ new Map();
38
  const macroChunks = /* @__PURE__ */ new Map();
39
  const fading = /* @__PURE__ */ new Set();
 
114
  sprite.width = sprite.height = MCHUNK * step * TILE6;
115
  return { sprite, tex: t };
116
  }
117
+ function coverZoom() {
118
+ if (!bounds || !app) return Z_MIN;
119
+ return Math.max(app.screen.width / (bounds.x1 - bounds.x0), app.screen.height / (bounds.y1 - bounds.y0));
120
+ }
121
+ function clampCamera() {
122
+ if (!bounds || !app) return;
123
+ const hw = app.screen.width / 2 / zoom, hh = app.screen.height / 2 / zoom;
124
+ const loX = bounds.x0 + hw, hiX = bounds.x1 - hw, loY = bounds.y0 + hh, hiY = bounds.y1 - hh;
125
+ camera.x = loX <= hiX ? Math.min(hiX, Math.max(loX, camera.x)) : (bounds.x0 + bounds.x1) / 2;
126
+ camera.y = loY <= hiY ? Math.min(hiY, Math.max(loY, camera.y)) : (bounds.y0 + bounds.y1) / 2;
127
+ }
128
  function reconcile() {
129
  if (!genRoot || !ctx || !app) return;
130
+ if (bounds) zoom = Math.max(zoom, coverZoom());
131
+ clampCamera();
132
  const sx = Math.round(app.screen.width / 2 - camera.x * zoom);
133
  const sy = Math.round(app.screen.height / 2 - camera.y * zoom);
134
  for (const L of [macroRoot, genRoot, shadowLayer, propLayer]) {
 
165
  }
166
  function reconcileDetail() {
167
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
168
+ let c0 = Math.floor((camera.x - halfW) / CHUNKPX) - 1, c1 = Math.floor((camera.x + halfW) / CHUNKPX) + 1;
169
+ let r0 = Math.floor((camera.y - halfH) / CHUNKPX) - 1, r1 = Math.floor((camera.y + halfH) / CHUNKPX) + 1;
170
+ if (bounds) {
171
+ c0 = Math.max(c0, Math.floor(bounds.tx0 / CHUNK5));
172
+ c1 = Math.min(c1, Math.floor((bounds.tx1 - 1) / CHUNK5));
173
+ r0 = Math.max(r0, Math.floor(bounds.ty0 / CHUNK5));
174
+ r1 = Math.min(r1, Math.floor((bounds.ty1 - 1) / CHUNK5));
175
+ }
176
  for (const [key, ch] of chunks) {
177
  const [cx, cy] = key.split(",").map(Number);
178
  if (cx < c0 - 1 || cx > c1 + 1 || cy < r0 - 1 || cy > r1 + 1) evictChunk(key, ch);
 
200
  function reconcileMacro(L) {
201
  const px = MCHUNK * (1 << L) * TILE6;
202
  const halfW = app.screen.width / 2 / zoom, halfH = app.screen.height / 2 / zoom;
203
+ let c0 = Math.floor((camera.x - halfW) / px) - 1, c1 = Math.floor((camera.x + halfW) / px) + 1;
204
+ let r0 = Math.floor((camera.y - halfH) / px) - 1, r1 = Math.floor((camera.y + halfH) / px) + 1;
205
+ if (bounds) {
206
+ const mt = MCHUNK * (1 << L);
207
+ c0 = Math.max(c0, Math.floor(bounds.tx0 / mt));
208
+ c1 = Math.min(c1, Math.floor((bounds.tx1 - 1) / mt));
209
+ r0 = Math.max(r0, Math.floor(bounds.ty0 / mt));
210
+ r1 = Math.min(r1, Math.floor((bounds.ty1 - 1) / mt));
211
+ }
212
  for (const [key, mc] of macroChunks) {
213
  const [ml, mx, my] = key.split(",").map(Number);
214
  if (ml !== L || mx < c0 - 1 || mx > c1 + 1 || my < r0 - 1 || my > r1 + 1) {
 
245
  }
246
  function zoomAt(factor, lx, ly) {
247
  if (!app) return;
248
+ const nz = Math.min(Z_MAX, Math.max(bounds ? coverZoom() : Z_MIN, zoom * factor));
249
  if (nz === zoom) return;
250
  const sw = app.screen.width, sh = app.screen.height;
251
  const wx = camera.x + (lx - sw / 2) / zoom, wy = camera.y + (ly - sh / 2) / zoom;
 
436
  if (!ch) return null;
437
  return config.tileIndexAt(wx, wy, ch.meta);
438
  }
439
+ function biomeAt(wx, wy) {
440
+ return config.biomeAt ? config.biomeAt(seed, wx, wy) : null;
441
+ }
442
+ function getBounds() {
443
+ return bounds ? { x0: bounds.x0, y0: bounds.y0, x1: bounds.x1, y1: bounds.y1 } : null;
444
+ }
445
  function setEnabled(v) {
446
  enabled = v;
447
  if (v) cameraDirty = true;
 
490
  ctx = null;
491
  texCache.clear();
492
  }
493
+ return { ready, regenerate, destroy, onChange, getSnapshot, getCamera, tileIndexAt, biomeAt, getBounds, zoomBy, setEnabled, onTick, getEntityLayer, getApp, screenToWorld, worldToScreen, tile: TILE6 };
494
  }
495
 
496
  // ../auto-battler/src/engine/rng.js
 
2149
  OVERWORLD_BIOMES.forEach((id, i) => {
2150
  RANK[id] = i;
2151
  });
2152
+ var overworldConfig = (seed, opts = {}) => {
2153
+ const region = opts.regionFn ?? biomeRegion;
2154
  const biomes = {};
2155
  for (const id of OVERWORLD_BIOMES) {
2156
  const factory = BIOME_FACTORIES[id];
 
2162
  tile: TILE5,
2163
  chunk: CHUNK4,
2164
  background: "#2b2f3a",
2165
+ bounds: opts.bounds,
2166
+ initialCamera: opts.initialCamera,
2167
  // Each biome loads its own sheets into its own ctx; then build the cross-biome transition atlas
2168
  // from their base grounds. Key everything for bake to route to.
2169
  async load(api) {
 
2183
  const winner = new Array(GW * GW);
2184
  const widx = (lx, ly) => (ly + 1) * GW + (lx + 1);
2185
  for (let ly = -1; ly <= CHUNK4; ly++) for (let lx = -1; lx <= CHUNK4; lx++) {
2186
+ winner[widx(lx, ly)] = region(seed2, x0 + lx, y0 + ly).id;
2187
  }
2188
  const at = (lx, ly) => winner[widx(lx, ly)];
2189
  const present = /* @__PURE__ */ new Set();
 
2233
  // little of the biome's internal terrain colour, cross-fading into the runner-up region across
2234
  // a thin border band so regions read as soft-edged lands, not hard cells.
2235
  macroColor(seed2, tx, ty) {
2236
+ const { id, id2, edge } = region(seed2, tx, ty);
2237
  let tint = REGION_TINT[id];
2238
  if (edge < MACRO_BLEND && id !== id2) tint = lerp3(tint, REGION_TINT[id2], 0.5 * (1 - edge / MACRO_BLEND));
2239
  return lerp3(tint, biomes[id].macroColor(seed2, tx, ty), TINT_MIX);
2240
  },
2241
+ // Which biome owns a world tile (no chunk meta needed) — for callers that key behaviour to the
2242
+ // land the player is on (free-roam enemy rosters, per-biome walkability).
2243
+ biomeAt(seed2, wx, wy) {
2244
+ return region(seed2, wx, wy).id;
2245
+ },
2246
  // Debug grid overlay: dispatch to whichever biome owns the tile, with that biome's chunk meta.
2247
  tileIndexAt(wx, wy, meta) {
2248
  if (!meta) return null;
web/tiny.js CHANGED
@@ -135,6 +135,30 @@ whenEl('skillforge-stage', (el) => { mountSkillForgePanel(el) })
135
  // link / sidebar ⚙), shared across pages via the runtime.js + tts.js singletons.
136
  mountSettingsPanel()
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  // Sidebar "⚙ Settings" item opens the SAME Gradio settings page as the footer link.
139
  // Wrap sidebar.js's tacNavigate (already set, since that's a non-module script): the
140
  // "Settings" nav target clicks Gradio's footer Settings button; everything else routes
@@ -156,15 +180,33 @@ window.tacNavigate = function (target) {
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
@@ -179,10 +221,11 @@ whenEl('battle-stage', async (el) => {
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
  })
 
135
  // link / sidebar ⚙), shared across pages via the runtime.js + tts.js singletons.
136
  mountSettingsPanel()
137
 
138
+ // Relocate Gradio's footer links — Use via API, Built with Gradio, Settings — into the sidebar's App
139
+ // slot (#tac-extlinks), then hide the footer. We move the LIVE elements, so each keeps its native
140
+ // behaviour (API panel / open gradio.app / open Settings). They are styled via .tac-extlink only —
141
+ // NOT .tac-nav-item, which would make sidebar.js hijack the click (mark active=white, swallow nav).
142
+ function relocateFooterLinks() {
143
+ const wanted = (t) => /^(use via api|built with gradio|settings)$/i.test((t || '').trim())
144
+ const move = () => {
145
+ const footer = document.querySelector('footer')
146
+ const host = document.getElementById('tac-extlinks')
147
+ if (!footer || !host) return false
148
+ const links = Array.prototype.filter.call(footer.querySelectorAll('a, button'), (e) => wanted(e.textContent))
149
+ if (links.length < 3) return false // wait until all three exist
150
+ const ref = document.querySelector('.tac-sidebar .tac-nav-item') // a real nav item, to copy its padding
151
+ const pad = ref ? getComputedStyle(ref).padding : ''
152
+ for (const e of links) { e.classList.add('tac-extlink'); if (pad) e.style.padding = pad; host.appendChild(e) }
153
+ footer.style.display = 'none'
154
+ return true
155
+ }
156
+ if (move()) return
157
+ const o = new MutationObserver(() => { if (move()) o.disconnect() })
158
+ o.observe(document.body, { childList: true, subtree: true })
159
+ }
160
+ relocateFooterLinks()
161
+
162
  // Sidebar "⚙ Settings" item opens the SAME Gradio settings page as the footer link.
163
  // Wrap sidebar.js's tacNavigate (already set, since that's a non-module script): the
164
  // "Settings" nav target clicks Gradio's footer Settings button; everything else routes
 
180
  // on proximity in real-time on the map. The old hardcoded 4v4 demo lived here; this is the shared
181
  // comboBattler surface (auto-battler source, bundled to /web/comboBattler.js).
182
  const sheetsOf = (c) => ({ idle: spriteUrl(c.idle), walk: spriteUrl(c.walk), attack: spriteUrl(c.attack), dmg: spriteUrl(c.dmg), die: spriteUrl(c.die) })
183
+ // Free-roam enemy rosters, one per biome band the spawner draws from the roster of whichever land
184
+ // the spawn point lands on. Forgotten Plains = human patrols, Orc Kingdom = orcs, Necropolis = the
185
+ // Dark Brotherhood. (slug + combat stats, resolved against characters.json.)
186
+ const GAME_ROSTERS = {
187
+ forgottenPlains: [
188
+ { name: 'Swordsman', slug: 'rts-humans-swordsman', stats: { hp: 110, armor: 20, basicDamage: 12 }, attackType: 'melee' },
189
+ { name: 'Spearman', slug: 'rts-humans-spearman', stats: { hp: 100, armor: 18, basicDamage: 11 }, attackType: 'melee' },
190
+ { name: 'Knight', slug: 'rts-humans-knight', stats: { hp: 150, armor: 35, basicDamage: 14 }, attackType: 'melee' },
191
+ { name: 'Archer', slug: 'rts-humans-archer', stats: { hp: 80, armor: 10, basicDamage: 12 }, attackType: 'ranged' },
192
+ ],
193
+ orc: [
194
+ { name: 'Orc Blade', slug: 'dark-orc-army-orc-blade', stats: { hp: 130, armor: 25, basicDamage: 13 }, attackType: 'melee' },
195
+ { name: 'Orc Raider', slug: 'dark-orc-army-orc-raider', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
196
+ { name: 'Orc Scout', slug: 'dark-orc-army-orc-scout', stats: { hp: 90, armor: 12, basicDamage: 10 }, attackType: 'melee' },
197
+ { name: 'Feral Berserker', slug: 'dark-orc-army-feral-berserker', stats: { hp: 160, armor: 30, basicDamage: 16 }, attackType: 'melee' },
198
+ { name: 'Feral Arbalist', slug: 'dark-orc-army-feral-arbalist', stats: { hp: 95, armor: 14, basicDamage: 13 }, attackType: 'ranged' },
199
+ { name: 'Cave Troll', slug: 'dark-orc-army-cave-troll', stats: { hp: 220, armor: 40, basicDamage: 20 }, attackType: 'melee' },
200
+ ],
201
+ necropolis: [
202
+ { name: 'Acolyte', slug: 'dark-brotherhood-acolyte', stats: { hp: 100, armor: 15, basicDamage: 10 }, attackType: 'melee' },
203
+ { name: 'Dark Cultist', slug: 'dark-brotherhood-dark-cultist', stats: { hp: 95, armor: 12, basicDamage: 12 }, attackType: 'ranged' },
204
+ { name: 'Devoted Blade', slug: 'dark-brotherhood-devoted-blade', stats: { hp: 120, armor: 20, basicDamage: 12 }, attackType: 'melee' },
205
+ { name: 'Dark Hound', slug: 'dark-brotherhood-dark-hound', stats: { hp: 85, armor: 10, basicDamage: 13 }, attackType: 'melee' },
206
+ { name: 'Zealot', slug: 'dark-brotherhood-zealot', stats: { hp: 110, armor: 18, basicDamage: 13 }, attackType: 'melee' },
207
+ { name: 'Dark Abomination', slug: 'dark-brotherhood-dark-abomination', stats: { hp: 200, armor: 35, basicDamage: 18 }, attackType: 'melee' },
208
+ ],
209
+ }
210
  // Persona class → engine profession (the engine has templates + skills for these five).
211
  const PERSONA_PROF = { Warrior: 'Warrior', Ranger: 'Ranger', Monk: 'Monk', Assassin: 'Assassin', Mage: 'Necromancer', Paladin: 'Monk', Cleric: 'Monk', Knight: 'Warrior' }
212
  const ENEMY_AGGRO = 220 // FIELD units (~8 tiles): enemy idles until the player is this near
 
221
  sheets: sheetsOf(pc),
222
  unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' }, // skills filled from profession
223
  }
224
+ const buildRoster = (list) => list.map((e) => {
225
  const c = chars[e.slug]; if (!c) return null
226
  return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO } }
227
  }).filter(Boolean)
228
+ const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
229
+ comboCtrl = mountComboBattler(PIXI, el, { seed: 1, player, rosters })
230
  await comboCtrl.ready
231
  })