// ── 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();