/* Custom frontend for the full game over gr.Server.
* Phase router: one view payload per request drives which screen/controls show.
* Custom tw-* classes (styled by shell.css); render.py HTML keeps game.css. */
const el = (id) => document.getElementById(id);
let SESSION = null;
let NARRATING = false;
let ALLOC = null;
let ALLOC_PROPOSED = null;
let TUTORIAL_PANELS = [];
let TUTORIAL_PAGE = 0;
let IS_BOSS_FLOOR = false;
let MOBILE_SIDEBAR_DEFAULTED = false;
let BGM_STARTED = false;
function startBgm() {
if (BGM_STARTED) return;
const audio = el("bgm");
audio.volume = 0.2;
audio.play().catch(() => {});
BGM_STARTED = true;
}
const LOADING_LINES = {
prepare_run: "Consulting the Tower's Memory…",
proceed: "The Tower reshapes the floor…",
embrace_evolution: "Forging your legend…",
continue_ascension: "Distilling your run into permanence…",
default: "The Tower considers…",
};
// ---------- transport ----------
async function callApi(name, dataArr, loadingKey, { noLoading = false } = {}) {
if (!noLoading) showLoading(LOADING_LINES[loadingKey] || LOADING_LINES.default);
try {
const post = await fetch(`/gradio_api/call/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: dataArr || [] }),
});
if (!post.ok) throw new Error(`POST /${name} -> ${post.status}`);
const posted = await post.json();
const eventId = posted.event_id || posted.hash || posted;
const res = await fetch(`/gradio_api/call/${name}/${eventId}`);
const text = await res.text();
let result = null;
for (const line of text.split("\n")) {
if (line.startsWith("data:")) {
try { result = JSON.parse(line.slice(5).trim()); } catch (_) {}
}
}
return Array.isArray(result) ? result[0] : result;
} finally {
if (!noLoading) hideLoading();
}
}
function showLoading(text) { el("loading-text").textContent = text; el("loading").hidden = false; }
function hideLoading() { el("loading").hidden = true; }
// ---------- narration animation ----------
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function animateNarration(events, speed) {
if (!events || !events.length) return;
const stayMs = speed === "cinematic" ? 1800 : 900;
const fadeMs = speed === "cinematic" ? 200 : 150;
NARRATING = true;
const decks = el("combat-decks");
if (decks) decks.classList.add("tw-narrating");
const narDiv = el("stage-narration");
for (let i = 0; i < events.length; i++) {
const e = events[i];
// New DOM element re-triggers narration-in CSS keyframe (fade-in from game.css)
narDiv.innerHTML = `
${escapeHtml(e.actor + ": " + e.text)}
`;
await sleep(stayMs);
// Smooth fade-out before next beat
const node = narDiv.firstElementChild;
if (node) { node.style.transition = `opacity ${fadeMs}ms`; node.style.opacity = "0"; }
await sleep(fadeMs);
}
narDiv.innerHTML = "";
if (decks) decks.classList.remove("tw-narrating");
NARRATING = false;
}
// ---------- tiny markdown ----------
function mdLite(md) {
if (!md) return "";
return md.split(/\n{2,}/).map((block) => {
let b = block.trim();
if (b.startsWith("## ")) return `${inline(b.slice(3))}
`;
if (b.startsWith("# ")) return `${inline(b.slice(2))}
`;
return `${inline(b).replace(/\n/g, "
")}
`;
}).join("");
}
function inline(s) { return s.replace(/\*\*(.+?)\*\*/g, "$1"); }
// ---------- screen switching ----------
const SCREENS = ["screen-title", "screen-allocation", "screen-starter", "screen-gameplay"];
function showScreen(id) { SCREENS.forEach((s) => { el(s).hidden = s !== id; }); }
// ---------- main router ----------
function applyView(view, { skipOverlay = false } = {}) {
if (!view) { console.error("empty view"); return; }
if (view.error) { console.error("server error:", view.error); return; }
if (view.session_id && view.session_id !== SESSION) MOBILE_SIDEBAR_DEFAULTED = false;
SESSION = view.session_id || SESSION;
if (view.backend) el("backend-badge").textContent = `Backend: ${view.backend}`;
// Populate tooltip data first — needed for setup screens too
el("button-tooltip-data").innerHTML = view.tooltips_html || "";
if (view.is_boss_floor !== undefined) IS_BOSS_FLOOR = view.is_boss_floor;
const phase = view.phase;
if (phase === "allocation") { renderAllocation(view); showScreen("screen-allocation"); bindTooltips(); return; }
if (phase === "run_setup" || phase === "starter_skill") { renderStarter(view); showScreen("screen-starter"); bindTooltips(); return; }
showScreen("screen-gameplay");
if (phase === "combat") startBgm();
el("stage").innerHTML = view.stage_html || "";
el("stage-narration").innerHTML = view.narration_html || "";
el("sidebar").innerHTML = view.sidebar_html || "";
el("latest-log").innerHTML = view.latest_log || "";
el("full-log").innerHTML = view.full_log || "";
let wantSidebar = view.sidebar_open !== false;
if (!MOBILE_SIDEBAR_DEFAULTED && window.innerWidth < 900) {
wantSidebar = false;
MOBILE_SIDEBAR_DEFAULTED = true;
}
applySidebar(wantSidebar);
renderCombatDecks(view);
if (!skipOverlay) renderOverlay(view);
bindTooltips();
}
// ---------- allocation ----------
const STAT_KEYS = ["strength", "agility", "intelligence", "endurance"];
const STAT_LABELS = { strength: "Strength", agility: "Agility", intelligence: "Intelligence", endurance: "Endurance" };
function renderAllocation(view) {
el("allocation-html").innerHTML = view.allocation.html || "";
if (!ALLOC) { ALLOC = { ...view.allocation.draft }; ALLOC_PROPOSED = { ...view.allocation.draft }; }
drawAllocationGrid();
}
function drawAllocationGrid() {
const remaining = 24 - STAT_KEYS.reduce((n, k) => n + ALLOC[k], 0);
const grid = el("allocation-grid");
grid.innerHTML = STAT_KEYS.map((k) => `
${STAT_LABELS[k]}
${ALLOC[k]}
`).join("") + `Points remaining: ${remaining}
`;
grid.querySelectorAll("button[data-stat]").forEach((b) => b.addEventListener("click", () => {
const k = b.dataset.stat, d = parseInt(b.dataset.delta, 10);
const next = ALLOC[k] + d, rem = 24 - STAT_KEYS.reduce((n, key) => n + ALLOC[key], 0);
if (next < 3 || next > 9) return;
if (d > 0 && rem <= 0) return;
ALLOC[k] = next; drawAllocationGrid();
}));
el("alloc-confirm").disabled = remaining !== 0;
}
// ---------- starter / difficulty / speed ----------
function renderStarter(view) {
const setup = view.setup || {};
el("starter-head").innerHTML = mdLite(view.floor_label ? `## ${view.floor_label}` : "## Choose Your First Technique");
const diff = setup.difficulty || "easy";
el("difficulty-row").innerHTML = ["easy", "normal"].map((m) => `
`).join("");
el("difficulty-row").querySelectorAll("button[data-diff]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("set_difficulty", [SESSION, b.dataset.diff]))));
const speed = view.combat_speed || "cinematic";
el("speed-row").innerHTML = [
["cinematic", "Cinematic — 3.5 s rounds"],
["fast", "Fast — 1.25 s rounds"],
].map(([s, label]) =>
``
).join("");
el("speed-row").querySelectorAll("button[data-speed]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("set_combat_speed", [SESSION, b.dataset.speed]))));
const needsPrepare = !!setup.needs_prepare;
el("prepare-run").hidden = !needsPrepare;
const grid = el("starter-grid");
if (needsPrepare) { grid.innerHTML = ""; }
else {
grid.innerHTML = (setup.starters || []).map((s) =>
``).join("");
grid.querySelectorAll("button[data-index]").forEach((b) =>
b.addEventListener("click", async () => { ALLOC = null; applyView(await callApi("choose_starter", [SESSION, parseInt(b.dataset.index, 10)])); }));
}
}
// ---------- combat decks ----------
function renderCombatDecks(view) {
const decks = el("combat-decks");
if (view.phase !== "combat") { decks.innerHTML = ""; return; }
const c = view.combat || {};
if (view.boss_thinking) {
decks.innerHTML = `The Boss Is ThinkingThe round stays while the next intent forms.
`;
return;
}
let html = `
Skills
`;
for (let i = 0; i < 6; i++) {
const sk = (c.skills || [])[i];
html += sk
? ``
: ``;
}
html += `
`;
decks.innerHTML = html;
const strike = el("action-strike"); if (strike) strike.addEventListener("click", () => doAct("strike", ""));
const defend = el("action-defend"); if (defend) defend.addEventListener("click", () => doAct("defend", ""));
decks.querySelectorAll("button[data-skill]").forEach((b) =>
b.addEventListener("click", () => doAct("skill", b.dataset.skill)));
}
function renderActingDecks() {
const label = IS_BOSS_FLOOR ? "The Boss is thinking…" : "Resolving turn…";
el("combat-decks").innerHTML = `
${label}
The round resolves…
`;
}
async function doAct(action, targetId) {
if (NARRATING) return;
renderActingDecks();
const view = await callApi("act", [SESSION, action, targetId || ""], undefined, { noLoading: true });
applyView(view, { skipOverlay: true });
await animateNarration(view.new_events || [], view.combat_speed || "cinematic");
renderOverlay(view);
}
// ---------- overlays ----------
function renderOverlay(view) {
const o = el("overlay");
const phase = view.phase;
let inner = "";
if (phase === "victory" && view.victory_step === "level_skill") {
inner = `${mdLite(view.victory_html)}
${(view.level_skills || []).map((s) =>
``).join("")}
`;
} else if (phase === "victory") {
if (view.reward_preview_html) {
inner = `${view.reward_preview_html}
`;
} else {
const picks = view.picks || [0, 0];
inner = `${mdLite(view.victory_html)}
${(view.rewards || []).map((r) =>
`
`).join("")}
`;
}
} else if (phase === "evolution_reveal") {
inner = `${view.evolution_html || ""}`;
} else if (phase === "evolution_healing") {
const [cur, max] = view.hp || [0, 0];
const gain = max - cur;
const healLine = gain > 0
? `HP: ${cur} → ${max} (+${gain})
`
: `You are already at full health.
`;
inner = `Evolution Complete
The Tower offers restoration as your new form settles.
${healLine}
`;
} else if (phase === "skill_replacement" || phase === "evolution_skill_replace") {
const rep = view.replacement || {};
inner = `${mdLite(`## Replace a Skill\nLearning **${rep.pending_name}** requires replacing one active skill.`)}
${(rep.skills || []).map((s) =>
``).join("")}
`;
} else if (phase === "defeat") {
inner = `${mdLite(view.defeat_html)}`;
} else if (phase === "ascension") {
const a = view.ascension || {};
inner = `${mdLite(`## Ascension ${a.level}\n**${a.name}** — ${a.description}`)}
`;
}
if (!inner) { o.hidden = true; o.innerHTML = ""; return; }
o.hidden = false;
o.innerHTML = `${inner}
`;
o.querySelectorAll("button[data-cmd]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("command", [SESSION, b.dataset.cmd], b.dataset.cmd))));
o.querySelectorAll("button[data-loot]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("choose", [SESSION, "loot", parseInt(b.dataset.loot, 10)]))));
o.querySelectorAll("button[data-level]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("choose", [SESSION, "level_skill", parseInt(b.dataset.level, 10)]))));
o.querySelectorAll("button[data-replace]").forEach((b) =>
b.addEventListener("click", async () => applyView(await callApi("choose", [SESSION, "replacement", parseInt(b.dataset.replace, 10)]))));
}
// ---------- sidebar ----------
function applySidebar(open) { el("sidebar-wrap").hidden = !open; el("show-sidebar").hidden = open; }
// ---------- tooltips ----------
function bindTooltips() {
const portal = el("tower-tooltip");
// span[data-target-id] tooltips from button-tooltip-data (skill cards, starter buttons, etc.)
el("button-tooltip-data").querySelectorAll("span[data-target-id]").forEach((span) => {
const target = document.getElementById(span.dataset.targetId);
if (!target || target._tipBound) return;
target._tipBound = true;
const text = span.dataset.tooltip;
target.addEventListener("mouseenter", () => {
portal.textContent = text;
const r = target.getBoundingClientRect();
portal.style.left = Math.min(window.innerWidth - 300, Math.max(8, r.left)) + "px";
portal.style.top = Math.max(8, r.top - 8) + "px";
portal.style.transform = "translateY(-100%)";
portal.classList.add("visible");
});
const hide = () => portal.classList.remove("visible");
target.addEventListener("mouseleave", hide);
target.addEventListener("click", hide);
});
// data-tip tooltips on battle frame elements (enemy/boss combatant-wrap)
const stage = el("stage");
if (stage) {
stage.querySelectorAll("[data-tip]").forEach((target) => {
if (target._tipBound) return;
target._tipBound = true;
const text = target.dataset.tip;
target.addEventListener("mouseenter", () => {
portal.textContent = text;
const r = target.getBoundingClientRect();
portal.style.left = Math.min(window.innerWidth - 300, Math.max(8, r.left)) + "px";
portal.style.top = Math.max(8, r.top - 8) + "px";
portal.style.transform = "translateY(-100%)";
portal.classList.add("visible");
});
const hide = () => portal.classList.remove("visible");
target.addEventListener("mouseleave", hide);
target.addEventListener("click", hide);
});
}
}
// ---------- tutorial ----------
function showTutorial(page) {
TUTORIAL_PAGE = Math.max(0, Math.min(page, TUTORIAL_PANELS.length - 1));
el("tutorial-content").innerHTML = TUTORIAL_PANELS[TUTORIAL_PAGE] || "No tutorial content loaded.
";
el("tutorial-prev").disabled = TUTORIAL_PAGE === 0;
el("tutorial-next").disabled = TUTORIAL_PAGE === TUTORIAL_PANELS.length - 1;
el("tutorial").hidden = false;
}
// ---------- util ----------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
// ---------- boot ----------
async function begin() {
ALLOC = null;
applyView(await callApi("new_run", []));
}
document.addEventListener("DOMContentLoaded", () => {
el("begin-btn").addEventListener("click", begin);
// Preload tutorial panels immediately — available before any game session starts
callApi("get_tutorial", []).then((v) => { if (v && v.tutorial_panels) TUTORIAL_PANELS = v.tutorial_panels; });
el("howtoplay-btn").addEventListener("click", () => showTutorial(0));
el("tutorial-prev").addEventListener("click", () => showTutorial(TUTORIAL_PAGE - 1));
el("tutorial-next").addEventListener("click", () => showTutorial(TUTORIAL_PAGE + 1));
el("tutorial-close").addEventListener("click", () => { el("tutorial").hidden = true; });
el("alloc-confirm").addEventListener("click", async () => {
const v = await callApi("allocate", [SESSION, ALLOC.strength, ALLOC.agility, ALLOC.intelligence, ALLOC.endurance]);
ALLOC = null; applyView(v);
});
el("alloc-reset").addEventListener("click", () => { ALLOC = { ...ALLOC_PROPOSED }; drawAllocationGrid(); });
el("prepare-run").addEventListener("click", async () => applyView(await callApi("prepare_run", [SESSION], "prepare_run")));
el("hide-sidebar").addEventListener("click", async () => applyView(await callApi("command", [SESSION, "toggle_sidebar"])));
el("show-sidebar").addEventListener("click", async () => applyView(await callApi("command", [SESSION, "toggle_sidebar"])));
el("mute-btn").addEventListener("click", () => {
const audio = el("bgm");
audio.muted = !audio.muted;
el("mute-btn").classList.toggle("tw-mute-off", audio.muted);
});
});