Files
fabula-ultima-html/fabula-ultima-sheet.html
2026-06-05 02:00:54 +00:00

802 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fabula Ultima — Character Sheet</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=Inconsolata:wght@400;600&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="fabula-ultima-sheet.css">
<script>(function () { var t = localStorage.getItem('fabulaUltimaTheme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); document.documentElement.dataset.theme = t; })()</script>
</head>
<body>
<header>
<div class="logo">Fabula Ultima</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('main')">Character</button>
<button class="tab" onclick="switchTab('classes')">Classes</button>
<button class="tab" onclick="switchTab('spells')">Arcana & Spells</button>
</div>
<div class="toolbar">
<button class="btn-save" onclick="saveSheet()">✦ Save</button>
<button class="btn-load" onclick="loadSheet()">↑ Load</button>
<div class="toolbar-sep"></div>
<button class="btn-save btn-export" onclick="exportSheet()">↓ Export JSON</button>
<button class="btn-load btn-import" onclick="importSheet()">↑ Import JSON</button>
<input type="file" id="importFileInput" accept=".json,application/json" style="display:none"
onchange="handleImportFile(this)">
<span class="save-status" id="saveStatus">Saved!</span>
<div class="toolbar-sep"></div>
<button class="btn-theme" id="themeToggle" onclick="toggleTheme()">☀ Light</button>
</div>
</header>
<!-- ══════════════════════════════════════════════════
PAGE 1: MAIN CHARACTER
══════════════════════════════════════════════════ -->
<div class="page active" id="page-main">
<!-- Row 1: Identity + Level -->
<div class="grid-2" style="margin-bottom:20px;">
<!-- Identity -->
<div class="section">
<div class="section-title"><span class="icon"></span> Identity & Traits</div>
<div class="field-row">
<div class="field" style="flex:2">
<label>Name</label>
<input type="text" id="charName" placeholder="Character name…">
</div>
<div class="field">
<label>Pronouns</label>
<input type="text" id="charPronouns" placeholder="they/them">
</div>
</div>
<div class="field">
<label>Identity</label>
<input type="text" id="charIdentity" placeholder="Who are you?">
</div>
<div class="field-row">
<div class="field">
<label>Theme</label>
<input type="text" id="charTheme" placeholder="Your theme…">
</div>
<div class="field">
<label>Origin</label>
<input type="text" id="charOrigin" placeholder="Where from?">
</div>
</div>
<div class="field">
<label>Traits (comma-separated)</label>
<textarea id="charTraits" placeholder="Brave, Reckless, Loyal to a fault…"
style="min-height:55px;"></textarea>
</div>
</div>
<!-- Level + XP + Defenses -->
<div style="display:flex; flex-direction:column; gap:16px;">
<div class="section">
<div class="section-title"><span class="icon"></span> Level & Experience</div>
<div class="grid-2" style="gap:14px;">
<div class="level-display">
<span class="level-num" id="levelDisplay">1</span>
<span class="level-text">Character Level</span>
<button class="add-btn" style="margin-top:10px; width:100%; justify-content:center;"
onclick="adjustLevel(1)">+ Level Up</button>
<button class="add-btn"
style="margin-top:4px; width:100%; justify-content:center; border-color:var(--border-bright); color:var(--text-dim);"
onclick="adjustLevel(-1)"> Level Down</button>
</div>
<div>
<div class="field">
<label>Experience Points (XP)</label>
<input type="number" id="xpCurrent" value="0" min="0" oninput="updateXPBar()">
</div>
<div class="xp-bar-wrap">
<div class="xp-bar" id="xpBar" style="width:0%"></div>
</div>
<div class="xp-label">
<span id="xpVal">0 XP</span>
<span>10 XP = Level</span>
</div>
<div class="field" style="margin-top:12px;">
<label>Zenit (currency)</label>
<input type="number" id="zenit" value="0" min="0">
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Defenses</div>
<div class="def-row">
<div class="def-block">
<label>Initiative Mod</label>
<input type="number" id="initMod" value="0">
</div>
<div class="def-block">
<label>Defense</label>
<input type="number" id="defense" value="0">
</div>
<div class="def-block">
<label>Magic Defense</label>
<input type="number" id="magDef" value="0">
</div>
</div>
</div>
</div>
</div>
<!-- Row 2: Attributes + Status + HP/MP/IP -->
<div class="grid-3" style="margin-bottom:20px;">
<!-- Attributes -->
<div class="section">
<div class="section-title"><span class="icon"></span> Attributes</div>
<div class="attr-grid">
<div class="attr-block">
<div class="attr-name">Dexterity</div>
<div class="attr-inputs">
<div style="flex:1">
<label style="font-size:0.48rem;">Base</label>
<input type="number" id="dex-base" value="6" min="6" max="12" oninput="recalcStats()">
</div>
<div class="attr-sep"></div>
<div style="flex:1">
<label style="font-size:0.48rem;">Current</label>
<input type="number" id="dex-cur" value="6" min="6" max="12">
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Insight</div>
<div class="attr-inputs">
<div style="flex:1">
<label style="font-size:0.48rem;">Base</label>
<input type="number" id="ins-base" value="6" min="6" max="12" oninput="recalcStats()">
</div>
<div class="attr-sep"></div>
<div style="flex:1">
<label style="font-size:0.48rem;">Current</label>
<input type="number" id="ins-cur" value="6" min="6" max="12">
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Might</div>
<div class="attr-inputs">
<div style="flex:1">
<label style="font-size:0.48rem;">Base</label>
<input type="number" id="mig-base" value="6" min="6" max="12" oninput="recalcStats()">
</div>
<div class="attr-sep"></div>
<div style="flex:1">
<label style="font-size:0.48rem;">Current</label>
<input type="number" id="mig-cur" value="6" min="6" max="12">
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Willpower</div>
<div class="attr-inputs">
<div style="flex:1">
<label style="font-size:0.48rem;">Base</label>
<input type="number" id="wlp-base" value="6" min="6" max="12" oninput="recalcStats()">
</div>
<div class="attr-sep"></div>
<div style="flex:1">
<label style="font-size:0.48rem;">Current</label>
<input type="number" id="wlp-cur" value="6" min="6" max="12">
</div>
</div>
</div>
</div>
</div>
<!-- Status Effects -->
<div class="section">
<div class="section-title"><span class="icon"></span> Status Effects</div>
<div class="status-grid" id="statusGrid">
<!-- generated by JS -->
</div>
</div>
<!-- HP / MP / IP -->
<div class="section">
<div class="section-title"><span class="icon"></span> Hit, Mind & Inventory Points</div>
<div class="vital-block">
<div class="vital-label hp">HP</div>
<div style="flex:1">
<div class="vital-formula">MIG×5 + Level + Other</div>
<div class="vital-inputs">
<input type="number" id="hpMax" placeholder="Max" oninput="updateCrisis()">
<div class="vital-sep">/ </div>
<input type="number" id="hpCur" placeholder="Cur" oninput="updateCrisis()">
<div class="crisis-badge" id="crisisBadge" style="display:none">CRISIS</div>
</div>
</div>
<button class="add-btn" style="padding:4px 8px;" onclick="calcHP()"
title="Auto-calculate from Might">Calc</button>
</div>
<div class="vital-block">
<div class="vital-label mp">MP</div>
<div style="flex:1">
<div class="vital-formula">WLP×5 + Level + Other</div>
<div class="vital-inputs">
<input type="number" id="mpMax" placeholder="Max">
<div class="vital-sep">/</div>
<input type="number" id="mpCur" placeholder="Cur">
</div>
</div>
<button class="add-btn" style="padding:4px 8px;" onclick="calcMP()"
title="Auto-calculate from Willpower">Calc</button>
</div>
<div class="vital-block">
<div class="vital-label ip">IP</div>
<div style="flex:1">
<div class="vital-formula">6 + Other</div>
<div class="vital-inputs">
<input type="number" id="ipMax" value="6" placeholder="Max">
<div class="vital-sep">/</div>
<input type="number" id="ipCur" value="6" placeholder="Cur">
</div>
</div>
</div>
</div>
</div>
<!-- Row 3: Fabula Points + Bonds -->
<div class="grid-2" style="margin-bottom:20px;">
<!-- Fabula Points -->
<div class="section">
<div class="section-title"><span class="icon"></span> Fabula Points</div>
<div style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">
<div>
<label>Current FP</label>
<input type="number" id="fpCount" value="0" min="0" max="20"
style="width:70px; text-align:center; font-size:1.4rem; font-family:var(--font-mono);"
oninput="renderFP()">
</div>
<div class="fp-pips" id="fpPips"></div>
</div>
<div class="fp-rules">
<div class="fp-rule"><strong>+1 FP</strong> if you have none at start of session.</div>
<div class="fp-rule"><strong>+1 FP</strong> when a Villain makes an entrance.</div>
<div class="fp-rule"><strong>+1 FP</strong> when you fumble a Check.</div>
<div class="fp-rule"><strong>+2 FP</strong> if you surrender at zero HP.</div>
<div class="fp-rule" style="margin-top:6px;"><strong>Spend 1 FP</strong> to invoke a trait: reroll one or both
dice.</div>
<div class="fp-rule"><strong>Spend 1 FP</strong> to invoke a bond: add its strength to the result.</div>
<div class="fp-rule"><strong>Spend 1 FP</strong> to alter the story.</div>
</div>
</div>
<!-- Bonds -->
<div class="section">
<div class="section-title"><span class="icon"></span> Bonds</div>
<div id="bondsContainer"></div>
</div>
</div>
<!-- Row 4: Equipment + Backpack -->
<div class="grid-2" style="margin-bottom:20px;">
<!-- Equipment -->
<div class="section">
<div class="section-title"><span class="icon"></span> Equipment</div>
<div class="martial-row" id="martialRow">
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div><span>Martial Armor</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div><span>Martial Shields</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div><span>Martial Melee</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div><span>Martial Ranged</span>
</div>
</div>
<div style="margin-top:14px;">
<div class="equip-row">
<div class="equip-slot">Accessory</div>
<div class="equip-fields">
<input type="text" id="acc-name" placeholder="Item name">
<input type="text" id="acc-desc" placeholder="Description / effect">
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Armor</div>
<div class="equip-fields">
<input type="text" id="arm-name" placeholder="Item name">
<input type="text" id="arm-desc" placeholder="Defense bonus / effect">
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Main Hand</div>
<div class="equip-fields">
<input type="text" id="mh-name" placeholder="Weapon name">
<input type="text" id="mh-desc" placeholder="Damage / effect">
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Off-Hand</div>
<div class="equip-fields">
<input type="text" id="oh-name" placeholder="Weapon / shield">
<input type="text" id="oh-desc" placeholder="Damage / effect">
</div>
</div>
</div>
</div>
<!-- Backpack & Notes -->
<div class="section">
<div class="section-title"><span class="icon"></span> Backpack & Notes</div>
<textarea id="backpack" placeholder="Items, notes, lore…" style="min-height:200px;"></textarea>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════
PAGE 2: CLASSES
══════════════════════════════════════════════════ -->
<div class="page" id="page-classes">
<div class="grid-2" style="margin-bottom:20px;">
<div class="section">
<div class="section-title"><span class="icon"></span> Primary Classes (up to 3 levels each)</div>
<div id="primaryClassContainer"></div>
<button class="add-btn" onclick="addPrimaryClass()">+ Add Primary Class</button>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Other Classes (max 3 non-mastered)</div>
<div id="otherClassContainer"></div>
<button class="add-btn" onclick="addOtherClass()">+ Add Class</button>
</div>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Heroic Skills</div>
<textarea id="heroicSkills" placeholder="Record your heroic skill abilities here…"
style="min-height:100px;"></textarea>
</div>
</div>
<!-- ══════════════════════════════════════════════════
PAGE 3: ARCANA & SPELLS
══════════════════════════════════════════════════ -->
<div class="page" id="page-spells">
<div class="section" style="margin-bottom:20px;">
<div class="section-title"><span class="icon"></span> Arcana & Spells</div>
<table class="spells-table" id="spellsTable">
<thead>
<tr>
<th class="spell-name-col">Name / Notes</th>
<th class="spell-mp-col">MP Cost</th>
<th class="spell-targets-col">Targets</th>
<th class="spell-dur-col">Duration</th>
<th class="spell-del-col"></th>
</tr>
</thead>
<tbody id="spellsBody"></tbody>
</table>
<button class="add-btn" onclick="addSpell()" style="margin-top:10px;">+ Add Spell / Arcana</button>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Rituals</div>
<div class="disciplines-row" id="disciplinesRow">
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Arcanism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Chimerism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Elementalism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Entropism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Ritualism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div><span>Spiritism</span>
</div>
</div>
<textarea id="ritualsNotes" placeholder="Record ritual details, components, and notes here…"
style="min-height:120px;"></textarea>
</div>
</div>
<script>
// ── STATE ──────────────────────────────────────────
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 ───────────────────────────────────────────
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();
tryAutoLoad();
}
// ── 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 = `<div class="status-check"></div><div class="status-label">${s}</div>`;
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 = `
<div class="bond-header">
<div class="bond-num">${idx + 1}</div>
<input type="text" placeholder="Bond target name…" value="${bond.name}" oninput="bonds[${idx}].name=this.value">
</div>
<div class="bond-feelings">
${FEELINGS.map(f => `
<div class="bond-feeling${bond.feelings.includes(f) ? ' active' : ''}" onclick="toggleFeeling(${idx},'${f}',this)">
<div class="bond-feeling-box">${bond.feelings.includes(f) ? '✓' : ''}</div>
<span>${f}</span>
</div>`).join('')}
</div>`;
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 `
<div class="class-block" id="${type}-class-${idx}">
<div class="class-header">
<input type="text" placeholder="Class name…" value="${cls.name || ''}" oninput="${type}Classes[${idx}].name=this.value">
<input type="text" placeholder="Free benefits…" value="${cls.benefits || ''}" oninput="${type}Classes[${idx}].benefits=this.value">
</div>
<div class="class-skills">
<textarea placeholder="Skill information…" oninput="${type}Classes[${idx}].skills=this.value">${cls.skills || ''}</textarea>
</div>
<div style="padding:6px 10px; border-top:1px solid var(--border); display:flex; justify-content:flex-end;">
<button class="spell-del-btn" onclick="removeClass('${type}',${idx})">✕ Remove</button>
</div>
</div>`;
}
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) => `
<tr>
<td class="spell-name-col">
<input type="text" placeholder="Spell / Arcana name…" value="${s.name || ''}" oninput="spells[${i}].name=this.value">
<textarea placeholder="Notes / effect description…" oninput="spells[${i}].notes=this.value">${s.notes || ''}</textarea>
</td>
<td class="spell-mp-col"><input type="number" placeholder="0" value="${s.mp || ''}" oninput="spells[${i}].mp=this.value" style="min-height:32px;"></td>
<td class="spell-targets-col"><input type="text" placeholder="Target(s)…" value="${s.targets || ''}" oninput="spells[${i}].targets=this.value"></td>
<td class="spell-dur-col"><input type="text" placeholder="Duration…" value="${s.duration || ''}" oninput="spells[${i}].duration=this.value"></td>
<td class="spell-del-col"><button class="spell-del-btn" onclick="removeSpell(${i})">✕</button></td>
</tr>`).join('');
}
function addSpell() { spells.push({}); renderSpells(); }
function removeSpell(i) { spells.splice(i, 1); renderSpells(); }
// ── SAVE / LOAD ────────────────────────────────────
function collectData() {
const get = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const statusesActive = [...document.querySelectorAll('.status-item.active-status')].map(el => el.dataset.status);
const martialChecked = [...document.querySelectorAll('.martial-item.checked')].map(el => el.querySelector('span').textContent);
const disciplinesChecked = [...document.querySelectorAll('.disc-item.checked')].map(el => el.querySelector('span').textContent);
return {
name: get('charName'), pronouns: get('charPronouns'),
identity: get('charIdentity'), theme: get('charTheme'), origin: get('charOrigin'),
traits: get('charTraits'),
level, xp: get('xpCurrent'), zenit: get('zenit'),
initMod: get('initMod'), defense: get('defense'), magDef: get('magDef'),
dexBase: get('dex-base'), dexCur: get('dex-cur'),
insBase: get('ins-base'), insCur: get('ins-cur'),
migBase: get('mig-base'), migCur: get('mig-cur'),
wlpBase: get('wlp-base'), wlpCur: get('wlp-cur'),
hpMax: get('hpMax'), hpCur: get('hpCur'),
mpMax: get('mpMax'), mpCur: get('mpCur'),
ipMax: get('ipMax'), ipCur: get('ipCur'),
fp: get('fpCount'),
backpack: get('backpack'),
accName: get('acc-name'), accDesc: get('acc-desc'),
armName: get('arm-name'), armDesc: get('arm-desc'),
mhName: get('mh-name'), mhDesc: get('mh-desc'),
ohName: get('oh-name'), ohDesc: get('oh-desc'),
heroicSkills: get('heroicSkills'),
ritualsNotes: get('ritualsNotes'),
statusesActive, martialChecked, disciplinesChecked,
bonds, primaryClasses, otherClasses, spells
};
}
function saveSheet() {
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.name); set('charPronouns', d.pronouns);
set('charIdentity', d.identity); set('charTheme', d.theme); set('charOrigin', d.origin);
set('charTraits', d.traits);
level = d.level || 1;
document.getElementById('levelDisplay').textContent = level;
set('xpCurrent', d.xp); set('zenit', d.zenit);
set('initMod', d.initMod); set('defense', d.defense); set('magDef', d.magDef);
set('dex-base', d.dexBase); set('dex-cur', d.dexCur);
set('ins-base', d.insBase); set('ins-cur', d.insCur);
set('mig-base', d.migBase); set('mig-cur', d.migCur);
set('wlp-base', d.wlpBase); set('wlp-cur', d.wlpCur);
set('hpMax', d.hpMax); set('hpCur', d.hpCur);
set('mpMax', d.mpMax); set('mpCur', d.mpCur);
set('ipMax', d.ipMax); set('ipCur', d.ipCur);
set('fpCount', d.fp);
set('backpack', d.backpack);
set('acc-name', d.accName); set('acc-desc', d.accDesc);
set('arm-name', d.armName); set('arm-desc', d.armDesc);
set('mh-name', d.mhName); set('mh-desc', d.mhDesc);
set('oh-name', d.ohName); set('oh-desc', d.ohDesc);
set('heroicSkills', d.heroicSkills);
set('ritualsNotes', d.ritualsNotes);
// Statuses
document.querySelectorAll('.status-item').forEach(el => {
const active = (d.statusesActive || []).includes(el.dataset.status);
el.classList.toggle('active-status', active);
el.querySelector('.status-check').textContent = active ? '✗' : '';
});
// Martial
document.querySelectorAll('.martial-item').forEach(el => {
const checked = (d.martialChecked || []).includes(el.querySelector('span').textContent);
el.classList.toggle('checked', checked);
el.querySelector('.martial-box').textContent = checked ? '✓' : '';
});
// Disciplines
document.querySelectorAll('.disc-item').forEach(el => {
const checked = (d.disciplinesChecked || []).includes(el.querySelector('span').textContent);
el.classList.toggle('checked', checked);
el.querySelector('.disc-box').textContent = checked ? '✓' : '';
});
if (d.bonds) { bonds = d.bonds; renderBonds(); }
if (d.primaryClasses) { primaryClasses = d.primaryClasses; renderPrimaryClasses(); }
if (d.otherClasses) { otherClasses = d.otherClasses; renderOtherClasses(); }
if (d.spells) { spells = d.spells; 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.name || '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);
}
// ── 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, 30000);
init();
</script>
</body>
</html>