Spaces:
Running
Running
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>
- app.py +7 -3
- web/comboBattler.js +81 -31
- web/shell/sidebar.js +3 -2
- 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
|
| 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
|
| 130 |
-
'@media (max-width:768px){#sprite-stage,#persona-stage,#diary-stage,#classes-stage,#enemies-stage,#worldmap-stage
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 2233 |
-
var GAME_H =
|
| 2234 |
var BAND_IDS = ["forgottenPlains", "orc", "necropolis"];
|
| 2235 |
var BAND_WARP_SCALE = 0.014;
|
| 2236 |
-
var
|
| 2237 |
-
var
|
| 2238 |
var bandSub = (seed, salt) => (Math.imul(seed | 0, 2654435761) ^ (salt | 0)) >>> 0;
|
| 2239 |
-
function
|
| 2240 |
-
const b1 =
|
| 2241 |
return (seed, x, y) => {
|
| 2242 |
-
const
|
|
|
|
| 2243 |
let i, j, d;
|
| 2244 |
-
if (
|
| 2245 |
i = 0;
|
| 2246 |
j = 1;
|
| 2247 |
-
d = b1 -
|
| 2248 |
-
} else if (
|
| 2249 |
i = 2;
|
| 2250 |
j = 1;
|
| 2251 |
-
d =
|
| 2252 |
} else {
|
| 2253 |
i = 1;
|
| 2254 |
-
const dl =
|
| 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 /
|
| 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:
|
| 2270 |
bounds: { x0: 0, y0: 0, x1: GAME_W, y1: GAME_H },
|
| 2271 |
-
// Start the view
|
| 2272 |
-
initialCamera: { x: 64 * TILE5, y: GAME_H
|
| 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 =
|
| 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
|
| 4934 |
-
|
| 4935 |
-
|
| 4936 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 4951 |
-
const pSkills =
|
| 4952 |
-
const players = [{ ...
|
| 4953 |
battle = makeTeamBattle({ seed, players, enemies: [], sandbox: true, freeCast: true, world, field });
|
| 4954 |
-
const start = worldToField(
|
| 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 |
-
|
| 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(
|
|
|
|
| 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,
|
| 4986 |
-
const w = fieldToWorld(fx2,
|
| 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 |
-
//
|
| 16 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
})
|