import "./fabula-ultima-sheet.css"; // ── STATE ────────────────────────────────────────── let urlMode = false; let level = 1; let fpCount = 0; let primaryClasses = []; let otherClasses = []; let spells = []; let bonds = [ { name: "", feelings: [] }, { name: "", feelings: [] }, { name: "", feelings: [] }, { name: "", feelings: [] }, { name: "", feelings: [] }, { name: "", feelings: [] }, ]; const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"]; const FEELINGS = [ "Admiration", "Inferiority", "Loyalty", "Mistrust", "Affection", "Hatred", ]; // ── TABS ─────────────────────────────────────────── function switchTab(tab) { document .querySelectorAll(".tab") .forEach((t) => t.classList.remove("active")); document .querySelectorAll(".page") .forEach((p) => p.classList.remove("active")); event.currentTarget.classList.add("active"); document.getElementById("page-" + tab).classList.add("active"); } // ── INIT ─────────────────────────────────────────── async function init() { const savedTheme = localStorage.getItem("fabulaUltimaTheme") || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); document.getElementById("themeToggle").textContent = savedTheme === "light" ? "☾ Dark" : "☀ Light"; renderStatuses(); renderFP(); renderBonds(); renderPrimaryClasses(); renderOtherClasses(); renderSpells(); updateXPBar(); if (!(await tryLoadFromURL())) tryAutoLoad(); } // ── URL STATE ────────────────────────────────────── async function compressToBase64(str) { const stream = new CompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(new TextEncoder().encode(str)); writer.close(); const buf = await new Response(stream.readable).arrayBuffer(); const bytes = new Uint8Array(buf); let binary = ""; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } async function decompressFromBase64(b64) { try { const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const stream = new DecompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(bytes); writer.close(); const buf = await new Response(stream.readable).arrayBuffer(); return new TextDecoder().decode(buf); } catch { // Fall back to legacy uncompressed URLs return decodeURIComponent(escape(atob(b64))); } } async function tryLoadFromURL() { const encoded = new URLSearchParams(window.location.search).get("c"); if (!encoded) return false; try { const json = await decompressFromBase64(encoded); applyData(JSON.parse(json)); urlMode = true; document.getElementById("urlBanner").style.display = "block"; return true; } catch (e) { console.warn("Could not load state from URL:", e); return false; } } // ── STATUS EFFECTS ───────────────────────────────── function renderStatuses() { const g = document.getElementById("statusGrid"); g.innerHTML = ""; STATUSES.forEach((s) => { const el = document.createElement("div"); el.className = "status-item"; el.dataset.status = s; el.onclick = () => { el.classList.toggle("active-status"); el.querySelector(".status-check").textContent = el.classList.contains( "active-status", ) ? "✗" : ""; }; el.innerHTML = `
${s}
`; g.appendChild(el); }); } // ── FABULA POINTS ────────────────────────────────── function renderFP() { fpCount = parseInt(document.getElementById("fpCount").value) || 0; const pips = document.getElementById("fpPips"); const total = Math.max(10, fpCount); pips.innerHTML = ""; for (let i = 0; i < total; i++) { const pip = document.createElement("div"); pip.className = "fp-pip" + (i < fpCount ? " filled" : ""); pip.onclick = () => { const newVal = i < fpCount ? i : i + 1; document.getElementById("fpCount").value = newVal; renderFP(); }; pips.appendChild(pip); } } // ── BONDS ────────────────────────────────────────── function renderBonds() { const container = document.getElementById("bondsContainer"); container.innerHTML = ""; bonds.forEach((bond, idx) => { const el = document.createElement("div"); el.className = "bond-block"; el.innerHTML = `
${idx + 1}
${FEELINGS.map( (f) => `
${bond.feelings.includes(f) ? "✓" : ""}
${f}
`, ).join("")}
`; container.appendChild(el); }); } function toggleFeeling(bondIdx, feeling, el) { const bond = bonds[bondIdx]; if (bond.feelings.includes(feeling)) { bond.feelings = bond.feelings.filter((f) => f !== feeling); el.classList.remove("active"); el.querySelector(".bond-feeling-box").textContent = ""; } else { bond.feelings.push(feeling); el.classList.add("active"); el.querySelector(".bond-feeling-box").textContent = "✓"; } } // ── LEVEL ────────────────────────────────────────── function adjustLevel(delta) { level = Math.max(1, Math.min(50, level + delta)); document.getElementById("levelDisplay").textContent = level; } // ── XP BAR ───────────────────────────────────────── function updateXPBar() { const xp = parseInt(document.getElementById("xpCurrent").value) || 0; const pct = Math.min((xp % 10) * 10, 100); document.getElementById("xpBar").style.width = pct + "%"; document.getElementById("xpVal").textContent = xp + " XP"; } // ── STAT CALC ────────────────────────────────────── function recalcStats() { /* auto-calc on keyup */ } function calcHP() { const mig = parseInt(document.getElementById("mig-base").value) || 6; const max = mig * 5 + level; document.getElementById("hpMax").value = max; if (!document.getElementById("hpCur").value) document.getElementById("hpCur").value = max; updateCrisis(); } function calcMP() { const wlp = parseInt(document.getElementById("wlp-base").value) || 6; const max = wlp * 5 + level; document.getElementById("mpMax").value = max; if (!document.getElementById("mpCur").value) document.getElementById("mpCur").value = max; } function updateCrisis() { const max = parseInt(document.getElementById("hpMax").value) || 0; const cur = parseInt(document.getElementById("hpCur").value) || 0; const badge = document.getElementById("crisisBadge"); badge.style.display = max > 0 && cur <= Math.floor(max / 2) ? "block" : "none"; } // ── MARTIAL ──────────────────────────────────────── function toggleMartial(el) { el.classList.toggle("checked"); el.querySelector(".martial-box").textContent = el.classList.contains( "checked", ) ? "✓" : ""; } // ── DISCIPLINES ──────────────────────────────────── function toggleDisc(el) { el.classList.toggle("checked"); el.querySelector(".disc-box").textContent = el.classList.contains("checked") ? "✓" : ""; } // ── CLASSES ──────────────────────────────────────── function classBlockHTML(cls, idx, type) { return `
`; } function renderPrimaryClasses() { document.getElementById("primaryClassContainer").innerHTML = primaryClasses .map((c, i) => classBlockHTML(c, i, "primary")) .join(""); } function renderOtherClasses() { document.getElementById("otherClassContainer").innerHTML = otherClasses .map((c, i) => classBlockHTML(c, i, "other")) .join(""); } function addPrimaryClass() { primaryClasses.push({}); renderPrimaryClasses(); } function addOtherClass() { otherClasses.push({}); renderOtherClasses(); } function removeClass(type, idx) { if (type === "primary") { primaryClasses.splice(idx, 1); renderPrimaryClasses(); } else { otherClasses.splice(idx, 1); renderOtherClasses(); } } // ── SPELLS ───────────────────────────────────────── function renderSpells() { const tbody = document.getElementById("spellsBody"); tbody.innerHTML = spells .map( (s, i) => ` `, ) .join(""); } function addSpell() { spells.push({}); renderSpells(); } function removeSpell(i) { spells.splice(i, 1); renderSpells(); } // ── SAVE / LOAD ──────────────────────────────────── // Short key legend (serialized form only; in-memory objects use full names): // n=name, pn=pronouns, id=identity, th=theme, og=origin, tr=traits // lv=level, xp=xp, z=zenit // im=initMod, df=defense, md=magDef // dxb=dexBase, dxc=dexCur, inb=insBase, inc=insCur // mgb=migBase, mgc=migCur, wpb=wlpBase, wpc=wlpCur // hx=hpMax, hc=hpCur, mx=mpMax, mc=mpCur, ix=ipMax, ic=ipCur // fp=fp, bp=backpack // acn=accName, acd=accDesc, amn=armName, amd=armDesc // mhn=mhName, mhd=mhDesc, ohn=ohName, ohd=ohDesc // hs=heroicSkills, rn=ritualsNotes // sa=statusesActive, ma=martialChecked, da=disciplinesChecked // bo=bonds, pc=primaryClasses, oc=otherClasses, sp=spells // Nested bonds: n=name, f=feelings // Nested classes: n=name, b=benefits, s=skills // Nested spells: n=name, nt=notes, mp=mp, tg=targets, dr=duration function collectData() { const get = (id) => { const el = document.getElementById(id); return el ? el.value : ""; }; const sa = [...document.querySelectorAll(".status-item.active-status")].map( (el) => el.dataset.status, ); const ma = [...document.querySelectorAll(".martial-item.checked")].map( (el) => el.querySelector("span").textContent, ); const da = [...document.querySelectorAll(".disc-item.checked")].map( (el) => el.querySelector("span").textContent, ); return { n: get("charName"), pn: get("charPronouns"), id: get("charIdentity"), th: get("charTheme"), og: get("charOrigin"), tr: get("charTraits"), lv: level, xp: get("xpCurrent"), z: get("zenit"), im: get("initMod"), df: get("defense"), md: get("magDef"), dxb: get("dex-base"), dxc: get("dex-cur"), inb: get("ins-base"), inc: get("ins-cur"), mgb: get("mig-base"), mgc: get("mig-cur"), wpb: get("wlp-base"), wpc: get("wlp-cur"), hx: get("hpMax"), hc: get("hpCur"), mx: get("mpMax"), mc: get("mpCur"), ix: get("ipMax"), ic: get("ipCur"), fp: get("fpCount"), bp: get("backpack"), acn: get("acc-name"), acd: get("acc-desc"), amn: get("arm-name"), amd: get("arm-desc"), mhn: get("mh-name"), mhd: get("mh-desc"), ohn: get("oh-name"), ohd: get("oh-desc"), hs: get("heroicSkills"), rn: get("ritualsNotes"), sa, ma, da, bo: bonds.map((b) => ({ n: b.name, f: b.feelings })), pc: primaryClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })), oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })), sp: spells.map((s) => ({ n: s.name, nt: s.notes, mp: s.mp, tg: s.targets, dr: s.duration, })), }; } function saveSheet(isAuto = false) { if (isAuto && urlMode) return; const data = collectData(); localStorage.setItem("fabulaUltimaSheet", JSON.stringify(data)); const st = document.getElementById("saveStatus"); st.classList.add("show"); setTimeout(() => st.classList.remove("show"), 2000); } function tryAutoLoad() { const raw = localStorage.getItem("fabulaUltimaSheet"); if (raw) try { applyData(JSON.parse(raw)); } catch (e) {} } function loadSheet() { const raw = localStorage.getItem("fabulaUltimaSheet"); if (!raw) return alert("No saved sheet found."); try { applyData(JSON.parse(raw)); } catch (e) { alert("Could not load sheet."); } } function applyData(d) { const set = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; }; set("charName", d.n ?? d.name); set("charPronouns", d.pn ?? d.pronouns); set("charIdentity", d.id ?? d.identity); set("charTheme", d.th ?? d.theme); set("charOrigin", d.og ?? d.origin); set("charTraits", d.tr ?? d.traits); level = d.lv ?? d.level ?? 1; document.getElementById("levelDisplay").textContent = level; set("xpCurrent", d.xp); set("zenit", d.z ?? d.zenit); set("initMod", d.im ?? d.initMod); set("defense", d.df ?? d.defense); set("magDef", d.md ?? d.magDef); set("dex-base", d.dxb ?? d.dexBase); set("dex-cur", d.dxc ?? d.dexCur); set("ins-base", d.inb ?? d.insBase); set("ins-cur", d.inc ?? d.insCur); set("mig-base", d.mgb ?? d.migBase); set("mig-cur", d.mgc ?? d.migCur); set("wlp-base", d.wpb ?? d.wlpBase); set("wlp-cur", d.wpc ?? d.wlpCur); set("hpMax", d.hx ?? d.hpMax); set("hpCur", d.hc ?? d.hpCur); set("mpMax", d.mx ?? d.mpMax); set("mpCur", d.mc ?? d.mpCur); set("ipMax", d.ix ?? d.ipMax); set("ipCur", d.ic ?? d.ipCur); set("fpCount", d.fp); set("backpack", d.bp ?? d.backpack); set("acc-name", d.acn ?? d.accName); set("acc-desc", d.acd ?? d.accDesc); set("arm-name", d.amn ?? d.armName); set("arm-desc", d.amd ?? d.armDesc); set("mh-name", d.mhn ?? d.mhName); set("mh-desc", d.mhd ?? d.mhDesc); set("oh-name", d.ohn ?? d.ohName); set("oh-desc", d.ohd ?? d.ohDesc); set("heroicSkills", d.hs ?? d.heroicSkills); set("ritualsNotes", d.rn ?? d.ritualsNotes); // Statuses const sa = d.sa ?? d.statusesActive ?? []; document.querySelectorAll(".status-item").forEach((el) => { const active = sa.includes(el.dataset.status); el.classList.toggle("active-status", active); el.querySelector(".status-check").textContent = active ? "✗" : ""; }); // Martial const ma = d.ma ?? d.martialChecked ?? []; document.querySelectorAll(".martial-item").forEach((el) => { const checked = ma.includes(el.querySelector("span").textContent); el.classList.toggle("checked", checked); el.querySelector(".martial-box").textContent = checked ? "✓" : ""; }); // Disciplines const da = d.da ?? d.disciplinesChecked ?? []; document.querySelectorAll(".disc-item").forEach((el) => { const checked = da.includes(el.querySelector("span").textContent); el.classList.toggle("checked", checked); el.querySelector(".disc-box").textContent = checked ? "✓" : ""; }); const rawBonds = d.bo ?? d.bonds; if (rawBonds) { bonds = rawBonds.map((b) => ({ name: b.n ?? b.name, feelings: b.f ?? b.feelings ?? [], })); renderBonds(); } const rawPrimary = d.pc ?? d.primaryClasses; if (rawPrimary) { primaryClasses = rawPrimary.map((c) => ({ name: c.n ?? c.name, benefits: c.b ?? c.benefits, skills: c.s ?? c.skills, })); renderPrimaryClasses(); } const rawOther = d.oc ?? d.otherClasses; if (rawOther) { otherClasses = rawOther.map((c) => ({ name: c.n ?? c.name, benefits: c.b ?? c.benefits, skills: c.s ?? c.skills, })); renderOtherClasses(); } const rawSpells = d.sp ?? d.spells; if (rawSpells) { spells = rawSpells.map((s) => ({ name: s.n ?? s.name, notes: s.nt ?? s.notes, mp: s.mp, targets: s.tg ?? s.targets, duration: s.dr ?? s.duration, })); renderSpells(); } renderFP(); updateXPBar(); updateCrisis(); } // ── EXPORT / IMPORT JSON ─────────────────────────── function exportSheet() { const data = collectData(); const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const charName = (data.n || "character").replace(/[^a-z0-9_\- ]/gi, "").trim() || "character"; a.href = url; a.download = charName + "-fabula-ultima.json"; a.click(); URL.revokeObjectURL(url); } function importSheet() { document.getElementById("importFileInput").value = ""; document.getElementById("importFileInput").click(); } function handleImportFile(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); applyData(data); saveSheet(); const st = document.getElementById("saveStatus"); st.textContent = "Imported!"; st.classList.add("show"); setTimeout(() => { st.classList.remove("show"); st.textContent = "Saved!"; }, 2500); } catch (err) { alert("Could not import: invalid JSON file."); } }; reader.readAsText(file); } async function copyShareURL() { const json = JSON.stringify(collectData()); const encoded = await compressToBase64(json); const url = window.location.origin + window.location.pathname + "?c=" + encodeURIComponent(encoded); await navigator.clipboard.writeText(url); const st = document.getElementById("copyStatus"); st.classList.add("show"); setTimeout(() => st.classList.remove("show"), 2000); } // ── THEME ────────────────────────────────────────── function toggleTheme() { const html = document.documentElement; const goLight = html.dataset.theme !== "light"; html.dataset.theme = goLight ? "light" : "dark"; document.getElementById("themeToggle").textContent = goLight ? "☾ Dark" : "☀ Light"; localStorage.setItem("fabulaUltimaTheme", html.dataset.theme); } // Auto-save every 30s setInterval(() => saveSheet(true), 30000); init();