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

Game: diagonal 2x map + half-size heroes; hidden overlay sidebar; persona-select state

Browse files

- web/*: rebuilt comboBattler bundle; mirror shell/sidebar.js (starts hidden).
- app.py: #battle-stage spans the full width so the sidebar overlays it as a drawer instead of pushing/resizing the canvas — fixes the blank map strip on toggle.
- web/tiny.js: bottom persona picker — mount the map with no hero, selectHero on pick, re-show the picker when there's no live hero (initial + on death).

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

Files changed (4) hide show
  1. app.py +7 -3
  2. web/comboBattler.js +81 -31
  3. web/shell/sidebar.js +3 -2
  4. web/tiny.js +47 -9
app.py CHANGED
@@ -122,12 +122,16 @@ 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,#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
 
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
+ # The Game stage ALWAYS spans the full width (left:0) — the sidebar (z-index 1000) slides OVER
132
+ # it as a drawer instead of pushing/resizing the canvas, so toggling it never leaves a blank
133
+ # strip where the map hasn't re-rendered into the resized area.
134
+ '#battle-stage{position:fixed !important;top:0;bottom:0;left:0;right:0;height:auto !important;z-index:1;}'
135
  # Gradio's relocated footer links (Use via API / Built with Gradio / Settings), styled to look
136
  # like the sidebar nav items WITHOUT the tac-nav-item class — that class makes sidebar.js hijack
137
  # the click (mark them active = white, and swallow navigation). web/tiny.js copies the reference
web/comboBattler.js CHANGED
@@ -2229,29 +2229,30 @@ function dominantForeign(at, tx, ty, A, rankA) {
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;
@@ -2260,16 +2261,16 @@ function bandedRegion(W) {
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
  }
@@ -4700,7 +4701,7 @@ function makeRoamWalkable(seed, biomeAt) {
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;
@@ -4930,10 +4931,49 @@ function mountComboBattler(pixi, host, opts = {}) {
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;
@@ -4947,11 +4987,11 @@ function mountComboBattler(pixi, host, opts = {}) {
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) {
@@ -4959,8 +4999,7 @@ function mountComboBattler(pixi, host, opts = {}) {
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);
@@ -4974,7 +5013,8 @@ function mountComboBattler(pixi, host, opts = {}) {
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;
@@ -4982,8 +5022,8 @@ function mountComboBattler(pixi, host, opts = {}) {
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 || [];
@@ -5030,6 +5070,16 @@ function mountComboBattler(pixi, host, opts = {}) {
5030
  buildControls();
5031
  offTick = map.onTick(tick);
5032
  emit();
 
 
 
 
 
 
 
 
 
 
5033
  })();
5034
  function getSnapshot() {
5035
  return snap();
@@ -5075,7 +5125,7 @@ function mountComboBattler(pixi, host, opts = {}) {
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;
 
2229
  }
2230
  return best;
2231
  }
2232
+ var GAME_W = 720;
2233
+ var GAME_H = 480;
2234
  var BAND_IDS = ["forgottenPlains", "orc", "necropolis"];
2235
  var BAND_WARP_SCALE = 0.014;
2236
+ var BAND_WARP_T = 0.035;
2237
+ var BAND_EDGE_T = 0.06;
2238
  var bandSub = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
2239
+ function diagonalRegion(W, H) {
2240
+ const b1 = 0.8, b2 = 1.2;
2241
  return (seed, x, y) => {
2242
+ const warp = BAND_WARP_T * (fbm(bandSub(seed, 1441), x * BAND_WARP_SCALE, y * BAND_WARP_SCALE) - 0.5) * 2;
2243
+ const t = x / W + (H - y) / H + warp;
2244
  let i, j, d;
2245
+ if (t < b1) {
2246
  i = 0;
2247
  j = 1;
2248
+ d = b1 - t;
2249
+ } else if (t >= b2) {
2250
  i = 2;
2251
  j = 1;
2252
+ d = t - b2;
2253
  } else {
2254
  i = 1;
2255
+ const dl = t - b1, dr = b2 - t;
2256
  if (dl < dr) {
2257
  j = 0;
2258
  d = dl;
 
2261
  d = dr;
2262
  }
2263
  }
2264
+ const edge = Math.max(0, Math.min(1, d / BAND_EDGE_T));
2265
  return { id: BAND_IDS[i], id2: BAND_IDS[j], edge };
2266
  };
2267
  }
2268
  function createGameOverworldMap(pixi, host, opts = {}) {
2269
  const config = overworldConfig(opts.seed ?? 1, {
2270
+ regionFn: diagonalRegion(GAME_W, GAME_H),
2271
  bounds: { x0: 0, y0: 0, x1: GAME_W, y1: GAME_H },
2272
+ // Start the view in the bottom-left (Forgotten Plains) corner.
2273
+ initialCamera: { x: 64 * TILE5, y: (GAME_H - 64) * TILE5 }
2274
  });
2275
  return createChunkedMap(pixi, host, { ...config, keyboardPan: opts.keyboardPan });
2276
  }
 
4701
 
4702
  // ../auto-battler/src/render/comboBattler.js
4703
  var TILE6 = 8;
4704
+ var SPRITE_TILES = 0.95;
4705
  var STEP = 0.05;
4706
  var SPAWN_TILES = 22;
4707
  var SPAWN_JITTER = 6;
 
4931
  const p = pa();
4932
  if (p) dot(p.x, p.y, 16766474);
4933
  }
4934
+ const sd = (o) => ({ idle: o?.idle, walk: o?.walk, attack: o?.attack, dmg: o?.dmg, die: o?.die });
4935
+ function teardownCombat() {
4936
+ try {
4937
+ offTick?.();
4938
+ } catch {
4939
+ }
4940
+ offTick = null;
4941
+ try {
4942
+ keyHandlers?.();
4943
+ } catch {
4944
+ }
4945
+ keyHandlers = null;
4946
+ try {
4947
+ tapHandlers?.();
4948
+ } catch {
4949
+ }
4950
+ tapHandlers = null;
4951
+ try {
4952
+ controlsEl?.remove();
4953
+ } catch {
4954
+ }
4955
+ controlsEl = null;
4956
+ try {
4957
+ markers?.destroy();
4958
+ } catch {
4959
+ }
4960
+ markers = null;
4961
+ try {
4962
+ combatRoot?.destroy({ children: true });
4963
+ } catch {
4964
+ }
4965
+ combatRoot = null;
4966
+ R = null;
4967
+ rings = null;
4968
+ battle = null;
4969
+ spawnFn = null;
4970
+ dead = false;
4971
+ acc.t = 0;
4972
+ cursor.logIdx = 0;
4973
+ moveTarget = null;
4974
+ }
4975
+ async function spawnHero(player) {
4976
+ if (!alive || !player) return;
4977
  const bnds = map.getBounds();
4978
  ARENA.ox = bnds.x0;
4979
  ARENA.oy = bnds.y0;
 
4987
  }
4988
  return { x: Math.max(0, Math.min(field.w, fx2)), y: Math.max(0, Math.min(field.h, fy)) };
4989
  };
4990
+ const pProf = player?.unit?.profession;
4991
+ const pSkills = player?.unit?.skills?.length ? player.unit.skills : CB_SKILLS.filter((s) => s.profession === pProf).slice(0, 3).map((s) => s.id);
4992
+ const players = [{ ...player?.unit || {}, name: player?.name || "Hero", control: "player", skills: pSkills }];
4993
  battle = makeTeamBattle({ seed, players, enemies: [], sandbox: true, freeCast: true, world, field });
4994
+ const start = worldToField(bnds.x0 + 64 * TILE6, bnds.y1 - 64 * TILE6);
4995
  const p0 = snapField(start.x, start.y);
4996
  const P = pa();
4997
  if (P) {
 
4999
  P.y = p0.y;
5000
  }
5001
  const defsById = {};
5002
+ defsById.P0 = { name: players[0].name, profession: players[0].profession, ...sd(player?.sheets) };
 
5003
  combatRoot = new Container();
5004
  combatRoot.scale.set(G);
5005
  combatRoot.position.set(ARENA.ox, ARENA.oy);
 
5013
  map.getEntityLayer().addChild(combatRoot);
5014
  markers = new Graphics();
5015
  map.getApp().stage.addChild(markers);
5016
+ const ch = await contentHeight(player?.sheets?.idle || player?.sheets?.walk);
5017
+ if (!alive) return;
5018
  depthScale.v = SPRITE_TILES * TILE6 / (ch * G);
5019
  R = await createCombatRenderer({ pixi, defsById, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
5020
  if (!alive) return;
 
5022
  let foeN = 0;
5023
  const deadAt = /* @__PURE__ */ new Map();
5024
  const spawnAcc = { t: SPAWN_INTERVAL };
5025
+ const rosterAt = (fx2, fy2) => {
5026
+ const w = fieldToWorld(fx2, fy2);
5027
  const b = map.biomeAt(Math.round(w.x / TILE6), Math.round(w.y / TILE6)) || "forgottenPlains";
5028
  const r = rosters[b];
5029
  return r && r.length ? r : rosters.forgottenPlains || [];
 
5070
  buildControls();
5071
  offTick = map.onTick(tick);
5072
  emit();
5073
+ }
5074
+ async function selectHero(player) {
5075
+ if (!alive || !player) return;
5076
+ if (battle || combatRoot) teardownCombat();
5077
+ await spawnHero(player);
5078
+ }
5079
+ const ready = (async () => {
5080
+ await map.ready;
5081
+ if (!alive) return;
5082
+ if (opts.player) await spawnHero(opts.player);
5083
  })();
5084
  function getSnapshot() {
5085
  return snap();
 
5125
  combatRoot = null;
5126
  listeners.clear();
5127
  }
5128
+ const ctrl = { ready, selectHero, getSnapshot, resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
5129
  if (typeof window !== "undefined") {
5130
  window.__comboSnap = () => ctrl.getSnapshot();
5131
  window.__combo = ctrl;
web/shell/sidebar.js CHANGED
@@ -12,8 +12,9 @@
12
  function collapsed() { return document.body.classList.contains('tac-collapsed') }
13
  function setCollapsed(c) { document.body.classList.toggle('tac-collapsed', !!c) }
14
 
15
- // Auto-collapse on mobile unless the user has explicitly toggled this session.
16
- function applyResponsive() { if (!userToggled) setCollapsed(isMobile()) }
 
17
 
18
  // Highlight the active nav item by its target label.
19
  function setActive(target) {
 
12
  function collapsed() { return document.body.classList.contains('tac-collapsed') }
13
  function setCollapsed(c) { document.body.classList.toggle('tac-collapsed', !!c) }
14
 
15
+ // Start collapsed (hidden drawer) and stay that way until the user opens it via the toggle —
16
+ // a mobile-style drawer on every viewport. Never auto-expands; once toggled, the user is in charge.
17
+ function applyResponsive() { if (!userToggled) setCollapsed(true) }
18
 
19
  // Highlight the active nav item by its target label.
20
  function setActive(target) {
web/tiny.js CHANGED
@@ -210,22 +210,60 @@ const GAME_ROSTERS = {
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  whenEl('battle-stage', async (el) => {
214
  unprose(el)
215
  const chars = await loadChars()
216
- // Active persona → class → character sheets + a directly-controlled hero (WASD + keys).
217
- const p = listPersonas()[0] || null
218
- const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
219
- const player = {
220
- name: p?.name || pc?.name || 'Hero',
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
  })
 
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
213
+ // A persona → controllable hero (class → sheets + engine profession).
214
+ const buildPlayer = (chars, p) => {
215
+ const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
216
+ return { name: p?.name || pc?.name || 'Hero', sheets: sheetsOf(pc), unit: { profession: PERSONA_PROF[p?.unitClass] || 'Warrior', name: p?.name || 'Hero' } }
217
+ }
218
+ // Bottom-of-screen hero picker: a card per saved persona (idle-sprite avatar + name). onPick gets the
219
+ // chosen persona record. Overlays the map (pointer-events only on the cards, so the map still pans).
220
+ function buildHeroPicker(host, personas, chars, onPick) {
221
+ const bar = document.createElement('div')
222
+ bar.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:6;display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 12px 18px;pointer-events:none;background:linear-gradient(to top,rgba(8,11,16,.86),rgba(8,11,16,0));font-family:var(--tac-font,system-ui)'
223
+ const title = document.createElement('div')
224
+ title.textContent = 'Choose your hero'
225
+ title.style.cssText = 'color:#e8e8e8;font-size:13px;letter-spacing:.08em;text-transform:uppercase;font-weight:600;text-shadow:0 1px 3px #000'
226
+ const row = document.createElement('div')
227
+ row.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap;justify-content:center;max-width:100%;pointer-events:auto'
228
+ for (const p of personas) {
229
+ const pc = chars[CLASS_SLUG[p?.unitClass]] || chars['true-heroes-iii-fighter']
230
+ const card = document.createElement('button')
231
+ card.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:4px;width:76px;padding:8px 6px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.92);color:#e8e8e8;cursor:pointer;font:600 11px var(--tac-font,system-ui)'
232
+ const av = document.createElement('div')
233
+ av.style.cssText = 'width:48px;height:48px;border-radius:8px;background:#0b0e12 0% 0%/400% 400% no-repeat;image-rendering:pixelated;border:1px solid #20262e'
234
+ if (pc?.idle) av.style.backgroundImage = `url(${spriteUrl(pc.idle)})`
235
+ const nm = document.createElement('div'); nm.textContent = p?.name || pc?.name || 'Hero'
236
+ nm.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
237
+ card.append(av, nm)
238
+ if (p?.unitClass) { const cl = document.createElement('div'); cl.textContent = p.unitClass; cl.style.cssText = 'color:#8a93a0;font-weight:500;font-size:10px'; card.append(cl) }
239
+ card.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onPick(p) })
240
+ row.appendChild(card)
241
+ }
242
+ bar.append(title, row); host.appendChild(bar)
243
+ return bar
244
+ }
245
  whenEl('battle-stage', async (el) => {
246
  unprose(el)
247
  const chars = await loadChars()
 
 
 
 
 
 
 
 
248
  const buildRoster = (list) => list.map((e) => {
249
  const c = chars[e.slug]; if (!c) return null
250
  return { name: e.name, sheets: sheetsOf(c), unit: { name: e.name, stats: e.stats, attackType: e.attackType, skills: [], aggroRadius: ENEMY_AGGRO } }
251
  }).filter(Boolean)
252
  const rosters = Object.fromEntries(Object.entries(GAME_ROSTERS).map(([k, v]) => [k, buildRoster(v)]))
253
+ // Mount with NO hero the map shows and the player picks a persona from the bottom picker. The
254
+ // picker reappears whenever there's no live hero (initial, and after the current one dies).
255
+ comboCtrl = mountComboBattler(PIXI, el, { seed: 1, rosters })
256
  await comboCtrl.ready
257
+ const FALLBACK = { name: 'Fighter', unitClass: 'Warrior' }
258
+ let picker = null
259
+ const showPicker = () => {
260
+ if (picker) return
261
+ const personas = listPersonas()
262
+ picker = buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, (p) => {
263
+ picker?.remove(); picker = null
264
+ comboCtrl.selectHero(buildPlayer(chars, p))
265
+ })
266
+ }
267
+ showPicker()
268
+ comboCtrl.onChange((s) => { if (s.over) showPicker() })
269
  })