/* 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 Thinking

The 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); }); });