| |
| |
|
|
|
|
| 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…",
|
| };
|
|
|
|
|
| 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; }
|
|
|
|
|
| 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];
|
|
|
| narDiv.innerHTML = `<div class="stage-narration narration-beat-${i}" role="status" aria-live="polite"><span>${escapeHtml(e.actor + ": " + e.text)}</span></div>`;
|
| await sleep(stayMs);
|
|
|
| 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;
|
| }
|
|
|
|
|
| function mdLite(md) {
|
| if (!md) return "";
|
| return md.split(/\n{2,}/).map((block) => {
|
| let b = block.trim();
|
| if (b.startsWith("## ")) return `<h2>${inline(b.slice(3))}</h2>`;
|
| if (b.startsWith("# ")) return `<h1>${inline(b.slice(2))}</h1>`;
|
| return `<p>${inline(b).replace(/\n/g, "<br>")}</p>`;
|
| }).join("");
|
| }
|
| function inline(s) { return s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); }
|
|
|
|
|
| const SCREENS = ["screen-title", "screen-allocation", "screen-starter", "screen-gameplay"];
|
| function showScreen(id) { SCREENS.forEach((s) => { el(s).hidden = s !== id; }); }
|
|
|
|
|
| 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}`;
|
|
|
|
|
| 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();
|
| }
|
|
|
|
|
| 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) => `
|
| <div class="stat-card">
|
| <strong>${STAT_LABELS[k]}</strong>
|
| <div class="stat-stepper">
|
| <button data-stat="${k}" data-delta="-1" ${ALLOC[k] <= 3 ? "disabled" : ""}>−</button>
|
| <span class="stat-value">${ALLOC[k]}</span>
|
| <button data-stat="${k}" data-delta="1" ${remaining <= 0 ? "disabled" : ""}>+</button>
|
| </div>
|
| </div>`).join("") + `<div class="alloc-remaining">Points remaining: <b>${remaining}</b></div>`;
|
| 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;
|
| }
|
|
|
|
|
| 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) => `
|
| <button class="diff-btn ${m === diff ? "selected" : ""}" data-diff="${m}" ${view.phase === "starter_skill" && view.backend !== "mock" ? "disabled" : ""}>
|
| ${m === "easy" ? "Easy — forgiving" : "Normal — stronger enemies"}
|
| </button>`).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]) =>
|
| `<button class="diff-btn ${s === speed ? "selected" : ""}" data-speed="${s}">${label}</button>`
|
| ).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) =>
|
| `<button class="starter-card" id="starter-skill-button-${s.index}" data-index="${s.index}">${escapeHtml(s.label)}</button>`).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)])); }));
|
| }
|
| }
|
|
|
|
|
| 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 = `<div class="tw-deck tw-thinking"><strong>The Boss Is Thinking</strong><p>The round stays while the next intent forms.</p></div>`;
|
| return;
|
| }
|
| let html = `
|
| <div class="tw-deck tw-deck-basic">
|
| <button class="tw-card" id="action-strike" ${c.strike_enabled ? "" : "disabled"}>Strike\n0 AP • Physical\nBasic attack</button>
|
| <button class="tw-card" id="action-defend" ${c.defend_enabled ? "" : "disabled"}>Defend\n0 AP • +1 AP\nGuard this turn</button>
|
| </div>
|
| <div class="tw-deck tw-deck-right tw-deck-open">
|
| <div class="tw-deck-title">Skills</div>
|
| <div class="tw-deck-grid">`;
|
| for (let i = 0; i < 6; i++) {
|
| const sk = (c.skills || [])[i];
|
| html += sk
|
| ? `<button class="tw-card" id="${sk.elem_id}" data-skill="${sk.id}" ${sk.enabled ? "" : "disabled"}>${escapeHtml(sk.label)}</button>`
|
| : `<button class="tw-card" disabled>—</button>`;
|
| }
|
| html += `</div></div>`;
|
| 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 = `
|
| <div class="tw-deck tw-deck-basic">
|
| <button class="tw-card" disabled>Strike\n0 AP • Physical\nBasic attack</button>
|
| <button class="tw-card" disabled>Defend\n0 AP • +1 AP\nGuard this turn</button>
|
| </div>
|
| <div class="tw-deck tw-deck-right tw-deck-open tw-thinking">
|
| <div class="tw-rune tw-rune-sm"></div>
|
| <strong>${label}</strong>
|
| <p>The round resolves…</p>
|
| </div>`;
|
| }
|
| 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);
|
| }
|
|
|
|
|
| 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)}
|
| <div class="level-skill-actions">${(view.level_skills || []).map((s) =>
|
| `<button class="tw-card" id="level-skill-button-${s.index}" data-level="${s.index}">${escapeHtml(s.label)}</button>`).join("")}</div>`;
|
| } else if (phase === "victory") {
|
| if (view.reward_preview_html) {
|
| inner = `${view.reward_preview_html}
|
| <div class="preview-actions">
|
| <button class="tw-card" data-cmd="cancel_preview">← Back</button>
|
| <button class="tw-card primary" data-cmd="claim_reward">Claim</button>
|
| </div>`;
|
| } else {
|
| const picks = view.picks || [0, 0];
|
| inner = `${mdLite(view.victory_html)}
|
| <div class="reward-actions">${(view.rewards || []).map((r) =>
|
| `<button class="tw-card" id="${r.elem_id}" data-loot="${r.index}" ${r.enabled ? "" : "disabled"}>
|
| ${r.icon ? `<img class="reward-icon" src="${r.icon}" alt="">` : ""}${escapeHtml(r.label)}${r.claimed ? " ✓" : ""}</button>`).join("")}</div>
|
| <div class="reward-footer">
|
| <span class="reward-count">Picks ${picks[0]}/${picks[1]}</span>
|
| <button class="tw-card" data-cmd="decline_rewards">Decline Remaining</button>
|
| <button class="tw-card primary" id="victory-proceed" data-cmd="proceed" ${view.proceed_ready ? "" : "disabled"}>Proceed</button>
|
| </div>`;
|
| }
|
| } else if (phase === "evolution_reveal") {
|
| inner = `${view.evolution_html || ""}<button class="tw-card primary" data-cmd="embrace_evolution">Embrace Evolution</button>`;
|
| } else if (phase === "evolution_healing") {
|
| const [cur, max] = view.hp || [0, 0];
|
| const gain = max - cur;
|
| const healLine = gain > 0
|
| ? `<p class="tw-heal-preview">HP: <b>${cur}</b> → <b>${max}</b> <span class="tw-heal-gain">(+${gain})</span></p>`
|
| : `<p style="color:#b8a6db">You are already at full health.</p>`;
|
| inner = `<h3 style="color:#d9c7ff;margin:0 0 .5rem">Evolution Complete</h3>
|
| <p style="color:#b8a6db;margin:0 0 .6rem">The Tower offers restoration as your new form settles.</p>
|
| ${healLine}
|
| <div class="heal-actions">
|
| <button class="tw-card primary" data-cmd="accept_heal">Accept Healing</button>
|
| <button class="tw-card" data-cmd="refuse_heal">Refuse</button>
|
| </div>`;
|
| } 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.`)}
|
| <div class="replacement-actions">${(rep.skills || []).map((s) =>
|
| `<button class="tw-card" id="replacement-button-${s.index}" data-replace="${s.index}">${escapeHtml(s.label)}</button>`).join("")}</div>
|
| <button class="tw-card" data-cmd="decline_skill">Decline — Keep Current Skills</button>`;
|
| } else if (phase === "defeat") {
|
| inner = `${mdLite(view.defeat_html)}<button class="tw-card primary" data-cmd="restart">Restart Run</button>`;
|
| } else if (phase === "ascension") {
|
| const a = view.ascension || {};
|
| inner = `${mdLite(`## Ascension ${a.level}\n**${a.name}** — ${a.description}`)}
|
| <button class="tw-card primary" data-cmd="continue_ascension">Begin Next Ascension</button>`;
|
| }
|
|
|
| if (!inner) { o.hidden = true; o.innerHTML = ""; return; }
|
| o.hidden = false;
|
| o.innerHTML = `<div class="tw-overlay-card">${inner}</div>`;
|
|
|
| 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)]))));
|
| }
|
|
|
|
|
| function applySidebar(open) { el("sidebar-wrap").hidden = !open; el("show-sidebar").hidden = open; }
|
|
|
|
|
| function bindTooltips() {
|
| const portal = el("tower-tooltip");
|
|
|
| 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);
|
| });
|
|
|
| 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);
|
| });
|
| }
|
| }
|
|
|
|
|
| function showTutorial(page) {
|
| TUTORIAL_PAGE = Math.max(0, Math.min(page, TUTORIAL_PANELS.length - 1));
|
| el("tutorial-content").innerHTML = TUTORIAL_PANELS[TUTORIAL_PAGE] || "<p>No tutorial content loaded.</p>";
|
| el("tutorial-prev").disabled = TUTORIAL_PAGE === 0;
|
| el("tutorial-next").disabled = TUTORIAL_PAGE === TUTORIAL_PANELS.length - 1;
|
| el("tutorial").hidden = false;
|
| }
|
|
|
|
|
| function escapeHtml(s) {
|
| return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
| }
|
|
|
|
|
| async function begin() {
|
| ALLOC = null;
|
| applyView(await callApi("new_run", []));
|
| }
|
| document.addEventListener("DOMContentLoaded", () => {
|
| el("begin-btn").addEventListener("click", begin);
|
|
|
| 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);
|
| });
|
| });
|
|
|