diff --git a/fabula-ultima-sheet.html b/fabula-ultima-sheet.html deleted file mode 100644 index 3c5f44d..0000000 --- a/fabula-ultima-sheet.html +++ /dev/null @@ -1,563 +0,0 @@ - - - - - - - Fabula Ultima — Character Sheet - - - - - -
- -
- - - - -
-
- Saved! - -
-
- - - - -
- -
- -
-
- Identity & Traits -
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- Level & Experience -
-
-
- 1 - Character Level - - -
-
-
- - -
-
-
-
-
- 0 XP - 10 XP = Level -
-
- - -
-
-
-
- -
-
- Defenses -
-
-
- - -
-
- - -
-
- - -
-
-
-
-
- - -
- -
-
- Attributes -
-
-
-
Dexterity
-
-
- - -
-
-
- - -
-
-
-
-
Insight
-
-
- - -
-
-
- - -
-
-
-
-
Might
-
-
- - -
-
-
- - -
-
-
-
-
Willpower
-
-
- - -
-
-
- - -
-
-
-
-
- - -
-
- Status Effects -
-
- -
-
- - -
-
- Hit, Mind & Inventory Points -
- -
-
HP
-
-
MIG×5 + Level + Other
-
- -
/
- - -
-
- -
- -
-
MP
-
-
WLP×5 + Level + Other
-
- -
/
- -
-
- -
- -
-
IP
-
-
6 + Other
-
- -
/
- -
-
-
-
-
- - -
- -
-
- Fabula Points -
-
-
- - -
-
-
-
-
- +1 FP if you have none at start of session. -
-
- +1 FP when a Villain makes an entrance. -
-
- +1 FP when you fumble a Check. -
-
- +2 FP if you surrender at zero HP. -
-
- Spend 1 FP to invoke a trait: reroll one or both - dice. -
-
- Spend 1 FP to invoke a bond: add its strength to - the result. -
-
- Spend 1 FP to alter the story. -
-
-
- - -
-
Bonds
-
-
-
- - -
- -
-
Equipment
-
-
-
- Martial Armor -
-
-
- Martial Shields -
-
-
- Martial Melee -
-
-
- Martial Ranged -
-
-
-
-
Accessory
-
- - -
-
-
-
Armor
-
- - -
-
-
-
Main Hand
-
- - -
-
-
-
Off-Hand
-
- - -
-
-
-
- - -
-
- Backpack & Notes -
- -
-
-
- - -
-
-
-
- Primary Classes (up to 3 levels each) -
-
- -
- -
-
- Other Classes (max 3 non-mastered) -
-
- -
-
- -
-
- Heroic Skills -
- -
-
- - -
-
-
- Arcana & Spells -
- - - - - - - - - - - -
Name / NotesMP CostTargetsDuration
- -
- -
-
Rituals
-
-
-
- Arcanism -
-
-
- Chimerism -
-
-
- Elementalism -
-
-
- Entropism -
-
-
- Ritualism -
-
-
- Spiritism -
-
- -
-
- - -
-
-
-
- Local Save -
-

- Save your character sheet to your browser's local storage, or load a - previously saved sheet. -

-
- - -
-
- -
-
JSON File
-

- Export your character to a JSON file for backup or sharing, or - import from a previously exported file. -

-
- - - -
-
- -
-
- Share via URL -
-

- Encode your character's current state into a shareable link. Anyone - who opens the link will see your character — auto-save is disabled - for viewers. -

-
- - Copied! -
-
-
-
- - - \ No newline at end of file diff --git a/fabula-ultima-sheet.js b/fabula-ultima-sheet.js deleted file mode 100644 index 18323f8..0000000 --- a/fabula-ultima-sheet.js +++ /dev/null @@ -1,617 +0,0 @@ -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(); diff --git a/src/CharacterSheet.jsx b/src/CharacterSheet.jsx new file mode 100644 index 0000000..fd1d894 --- /dev/null +++ b/src/CharacterSheet.jsx @@ -0,0 +1,1387 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import "../fabula-ultima-sheet.css"; + +const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"]; +const FEELINGS = [ + "Admiration", + "Inferiority", + "Loyalty", + "Mistrust", + "Affection", + "Hatred", +]; +const MARTIAL_ITEMS = [ + "Martial Armor", + "Martial Shields", + "Martial Melee", + "Martial Ranged", +]; +const DISCIPLINES = [ + "Arcanism", + "Chimerism", + "Elementalism", + "Entropism", + "Ritualism", + "Spiritism", +]; + +const BLANK_FIELDS = { + charName: "", + charPronouns: "", + charIdentity: "", + charTheme: "", + charOrigin: "", + charTraits: "", + xpCurrent: 0, + zenit: 0, + initMod: 0, + defense: 0, + magDef: 0, + dexBase: 6, + dexCur: 6, + insBase: 6, + insCur: 6, + migBase: 6, + migCur: 6, + wlpBase: 6, + wlpCur: 6, + hpMax: "", + hpCur: "", + mpMax: "", + mpCur: "", + ipMax: 6, + ipCur: 6, + backpack: "", + heroicSkills: "", + ritualsNotes: "", + accName: "", + accDesc: "", + armName: "", + armDesc: "", + mhName: "", + mhDesc: "", + ohName: "", + ohDesc: "", +}; + +const BLANK_BONDS = Array.from({ length: 6 }, () => ({ + name: "", + feelings: [], +})); + +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 { + return decodeURIComponent(escape(atob(b64))); + } +} + +export default function CharacterSheet() { + const [activeTab, setActiveTab] = useState("main"); + const [urlMode, setUrlMode] = useState(false); + const [saveStatus, setSaveStatus] = useState(false); + const [copyStatus, setCopyStatus] = useState(false); + const [level, setLevel] = useState(1); + const [fp, setFp] = useState(0); + const [fields, setFields] = useState(BLANK_FIELDS); + const [bonds, setBonds] = useState(BLANK_BONDS); + const [primaryClasses, setPrimaryClasses] = useState([]); + const [otherClasses, setOtherClasses] = useState([]); + const [spells, setSpells] = useState([]); + const [statuses, setStatuses] = useState({}); + const [martial, setMartial] = useState({}); + const [disciplines, setDisciplines] = useState({}); + const [theme, setTheme] = useState( + () => + localStorage.getItem("fabulaUltimaTheme") || + (window.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark"), + ); + + const importFileRef = useRef(null); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + localStorage.setItem("fabulaUltimaTheme", theme); + }, [theme]); + + const f = useCallback( + (key, val) => setFields((prev) => ({ ...prev, [key]: val })), + [], + ); + + const xp = parseInt(fields.xpCurrent) || 0; + const xpPct = Math.min((xp % 10) * 10, 100); + const hpMax = parseInt(fields.hpMax) || 0; + const hpCur = parseInt(fields.hpCur) || 0; + const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2); + const fpTotal = Math.max(10, fp); + + const collectData = useCallback( + () => ({ + n: fields.charName, + pn: fields.charPronouns, + id: fields.charIdentity, + th: fields.charTheme, + og: fields.charOrigin, + tr: fields.charTraits, + lv: level, + xp: fields.xpCurrent, + z: fields.zenit, + im: fields.initMod, + df: fields.defense, + md: fields.magDef, + dxb: fields.dexBase, + dxc: fields.dexCur, + inb: fields.insBase, + inc: fields.insCur, + mgb: fields.migBase, + mgc: fields.migCur, + wpb: fields.wlpBase, + wpc: fields.wlpCur, + hx: fields.hpMax, + hc: fields.hpCur, + mx: fields.mpMax, + mc: fields.mpCur, + ix: fields.ipMax, + ic: fields.ipCur, + fp, + bp: fields.backpack, + acn: fields.accName, + acd: fields.accDesc, + amn: fields.armName, + amd: fields.armDesc, + mhn: fields.mhName, + mhd: fields.mhDesc, + ohn: fields.ohName, + ohd: fields.ohDesc, + hs: fields.heroicSkills, + rn: fields.ritualsNotes, + sa: STATUSES.filter((s) => statuses[s]), + ma: MARTIAL_ITEMS.filter((m) => martial[m]), + da: DISCIPLINES.filter((d) => disciplines[d]), + 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, + })), + }), + [ + fields, + level, + fp, + bonds, + primaryClasses, + otherClasses, + spells, + statuses, + martial, + disciplines, + ], + ); + + const applyData = useCallback((d) => { + setFields({ + charName: d.n ?? d.name ?? "", + charPronouns: d.pn ?? d.pronouns ?? "", + charIdentity: d.id ?? d.identity ?? "", + charTheme: d.th ?? d.theme ?? "", + charOrigin: d.og ?? d.origin ?? "", + charTraits: d.tr ?? d.traits ?? "", + xpCurrent: d.xp ?? 0, + zenit: d.z ?? d.zenit ?? 0, + initMod: d.im ?? d.initMod ?? 0, + defense: d.df ?? d.defense ?? 0, + magDef: d.md ?? d.magDef ?? 0, + dexBase: d.dxb ?? d.dexBase ?? 6, + dexCur: d.dxc ?? d.dexCur ?? 6, + insBase: d.inb ?? d.insBase ?? 6, + insCur: d.inc ?? d.insCur ?? 6, + migBase: d.mgb ?? d.migBase ?? 6, + migCur: d.mgc ?? d.migCur ?? 6, + wlpBase: d.wpb ?? d.wlpBase ?? 6, + wlpCur: d.wpc ?? d.wlpCur ?? 6, + hpMax: d.hx ?? d.hpMax ?? "", + hpCur: d.hc ?? d.hpCur ?? "", + mpMax: d.mx ?? d.mpMax ?? "", + mpCur: d.mc ?? d.mpCur ?? "", + ipMax: d.ix ?? d.ipMax ?? 6, + ipCur: d.ic ?? d.ipCur ?? 6, + backpack: d.bp ?? d.backpack ?? "", + accName: d.acn ?? d.accName ?? "", + accDesc: d.acd ?? d.accDesc ?? "", + armName: d.amn ?? d.armName ?? "", + armDesc: d.amd ?? d.armDesc ?? "", + mhName: d.mhn ?? d.mhName ?? "", + mhDesc: d.mhd ?? d.mhDesc ?? "", + ohName: d.ohn ?? d.ohName ?? "", + ohDesc: d.ohd ?? d.ohDesc ?? "", + heroicSkills: d.hs ?? d.heroicSkills ?? "", + ritualsNotes: d.rn ?? d.ritualsNotes ?? "", + }); + setLevel(d.lv ?? d.level ?? 1); + setFp(parseInt(d.fp) || 0); + + const sa = d.sa ?? d.statusesActive ?? []; + setStatuses(Object.fromEntries(STATUSES.map((s) => [s, sa.includes(s)]))); + const ma = d.ma ?? d.martialChecked ?? []; + setMartial( + Object.fromEntries(MARTIAL_ITEMS.map((m) => [m, ma.includes(m)])), + ); + const da = d.da ?? d.disciplinesChecked ?? []; + setDisciplines( + Object.fromEntries(DISCIPLINES.map((disc) => [disc, da.includes(disc)])), + ); + + const rawBonds = d.bo ?? d.bonds; + if (rawBonds) + setBonds( + rawBonds.map((b) => ({ + name: b.n ?? b.name ?? "", + feelings: b.f ?? b.feelings ?? [], + })), + ); + + const rawPrimary = d.pc ?? d.primaryClasses; + if (rawPrimary) + setPrimaryClasses( + rawPrimary.map((c) => ({ + name: c.n ?? c.name ?? "", + benefits: c.b ?? c.benefits ?? "", + skills: c.s ?? c.skills ?? "", + })), + ); + + const rawOther = d.oc ?? d.otherClasses; + if (rawOther) + setOtherClasses( + rawOther.map((c) => ({ + name: c.n ?? c.name ?? "", + benefits: c.b ?? c.benefits ?? "", + skills: c.s ?? c.skills ?? "", + })), + ); + + const rawSpells = d.sp ?? d.spells; + if (rawSpells) + setSpells( + 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 ?? "", + })), + ); + }, []); + + // Init + useEffect(() => { + const encoded = new URLSearchParams(window.location.search).get("c"); + if (encoded) { + decompressFromBase64(encoded) + .then((json) => { + applyData(JSON.parse(json)); + setUrlMode(true); + }) + .catch(() => tryAutoLoad()); + } else { + tryAutoLoad(); + } + function tryAutoLoad() { + const raw = localStorage.getItem("fabulaUltimaSheet"); + if (raw) + try { + applyData(JSON.parse(raw)); + } catch (e) {} + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-save every 30s + useEffect(() => { + const id = setInterval(() => { + if (urlMode) return; + localStorage.setItem("fabulaUltimaSheet", JSON.stringify(collectData())); + }, 30000); + return () => clearInterval(id); + }, [urlMode, collectData]); + + const saveSheet = useCallback(() => { + if (urlMode) return; + localStorage.setItem("fabulaUltimaSheet", JSON.stringify(collectData())); + setSaveStatus(true); + setTimeout(() => setSaveStatus(false), 2000); + }, [urlMode, collectData]); + + const loadSheet = useCallback(() => { + const raw = localStorage.getItem("fabulaUltimaSheet"); + if (!raw) return alert("No saved sheet found."); + try { + applyData(JSON.parse(raw)); + } catch { + alert("Could not load sheet."); + } + }, [applyData]); + + const exportSheet = useCallback(() => { + const data = collectData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = + ((data.n || "character").replace(/[^a-z0-9_\- ]/gi, "").trim() || + "character") + "-fabula-ultima.json"; + a.click(); + URL.revokeObjectURL(url); + }, [collectData]); + + const handleImportFile = useCallback( + (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + applyData(JSON.parse(ev.target.result)); + saveSheet(); + } catch { + alert("Could not import: invalid JSON file."); + } + }; + reader.readAsText(file); + }, + [applyData, saveSheet], + ); + + const copyShareURL = useCallback(async () => { + const encoded = await compressToBase64(JSON.stringify(collectData())); + await navigator.clipboard.writeText( + window.location.origin + + window.location.pathname + + "?c=" + + encodeURIComponent(encoded), + ); + setCopyStatus(true); + setTimeout(() => setCopyStatus(false), 2000); + }, [collectData]); + + const calcHP = useCallback(() => { + const mig = parseInt(fields.migBase) || 6; + const max = mig * 5 + level; + setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max })); + }, [fields.migBase, level]); + + const calcMP = useCallback(() => { + const wlp = parseInt(fields.wlpBase) || 6; + const max = wlp * 5 + level; + setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max })); + }, [fields.wlpBase, level]); + + const toggleStatus = (s) => + setStatuses((prev) => ({ ...prev, [s]: !prev[s] })); + const toggleMartial = (m) => + setMartial((prev) => ({ ...prev, [m]: !prev[m] })); + const toggleDisc = (d) => + setDisciplines((prev) => ({ ...prev, [d]: !prev[d] })); + + const toggleFeeling = (bondIdx, feeling) => { + setBonds((prev) => + prev.map((b, i) => { + if (i !== bondIdx) return b; + const has = b.feelings.includes(feeling); + return { + ...b, + feelings: has + ? b.feelings.filter((f) => f !== feeling) + : [...b.feelings, feeling], + }; + }), + ); + }; + + return ( + <> +
+
Fabula Ultima
+
+ {["main", "classes", "spells", "manage"].map((tab, i) => ( + + ))} +
+
+ + Saved! + + +
+
+ + {urlMode && ( +
+ Viewing a shared character + — auto-save is disabled. Use Manage → Save to Browser{" "} + to keep any changes. +
+ )} + + {/* ── PAGE 1: MAIN ── */} +
+ {/* Row 1: Identity + Level */} +
+
+
+ Identity & Traits +
+
+
+ + f("charName", e.target.value)} + placeholder="Character name…" + /> +
+
+ + f("charPronouns", e.target.value)} + placeholder="they/them" + /> +
+
+
+ + f("charIdentity", e.target.value)} + placeholder="Who are you?" + /> +
+
+
+ + f("charTheme", e.target.value)} + placeholder="Your theme…" + /> +
+
+ + f("charOrigin", e.target.value)} + placeholder="Where from?" + /> +
+
+
+ +