Files
fabula-ultima-html/fabula-ultima-sheet.html

1407 lines
51 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">
<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">
<style>
:root {
--bg: #0d0f0e;
--surface: #141816;
--surface2: #1b1f1d;
--surface3: #222826;
--border: #2e3830;
--border-bright: #3d4f47;
--teal: #4ecdc4;
--teal-dim: #2a6b66;
--gold: #c9a84c;
--gold-dim: #7a6130;
--red: #c0392b;
--text: #d4cfc4;
--text-dim: #7a8a82;
--text-bright: #ede8dc;
--crisis: #e74c3c;
--font-display: 'Cinzel', serif;
--font-body: 'Crimson Text', serif;
--font-mono: 'Inconsolata', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 16px;
min-height: 100vh;
background-image:
radial-gradient(ellipse at 20% 10%, rgba(78,205,196,0.04) 0%, transparent 50%),
radial-gradient(ellipse at 80% 90%, rgba(201,168,76,0.04) 0%, transparent 50%);
}
/* ── HEADER ─────────────────────────────────────── */
header {
background: linear-gradient(135deg, var(--surface) 0%, #0f1614 100%);
border-bottom: 1px solid var(--border-bright);
padding: 20px 32px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.logo {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--teal);
letter-spacing: 0.15em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 10px;
}
.logo::before {
content: '✦';
color: var(--gold);
font-size: 0.9em;
}
.tabs {
display: flex;
gap: 4px;
}
.tab {
font-family: var(--font-display);
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 7px 16px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.tab:hover { border-color: var(--teal-dim); color: var(--teal); }
.tab.active { background: var(--teal-dim); border-color: var(--teal); color: var(--text-bright); }
/* ── LAYOUT ──────────────────────────────────────── */
.page { display: none; padding: 28px 32px; max-width: 1400px; margin: 0 auto; }
.page.active { display: block; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
.col-span-2 { grid-column: span 2; }
/* ── SECTION ─────────────────────────────────────── */
.section {
background: var(--surface);
border: 1px solid var(--border);
padding: 18px 20px;
position: relative;
}
.section::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
background: var(--teal-dim);
}
.section-title {
font-family: var(--font-display);
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--teal);
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.section-title .icon { color: var(--gold); }
/* ── FIELDS ──────────────────────────────────────── */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
label {
display: block;
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 5px;
}
input[type="text"], input[type="number"], textarea, select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-bright);
font-family: var(--font-body);
font-size: 1rem;
padding: 8px 11px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
-webkit-appearance: none;
}
input[type="text"]:focus, input[type="number"]:focus, textarea:focus, select:focus {
border-color: var(--teal);
box-shadow: 0 0 0 2px rgba(78,205,196,0.12);
}
textarea { resize: vertical; min-height: 70px; }
input[type="number"] { font-family: var(--font-mono); }
.field-row { display: flex; gap: 12px; }
.field-row .field { flex: 1; }
/* ── ATTRIBUTES ──────────────────────────────────── */
.attr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.attr-block {
background: var(--surface2);
border: 1px solid var(--border);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.attr-name {
font-family: var(--font-display);
font-size: 0.65rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--gold);
}
.attr-inputs { display: flex; gap: 8px; align-items: center; }
.attr-inputs input { font-family: var(--font-mono); font-size: 1.2rem; text-align: center; width: 52px; flex-shrink: 0; }
.attr-sep { color: var(--text-dim); font-size: 0.8rem; }
/* ── STATUS EFFECTS ──────────────────────────────── */
.status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.status-item {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 7px 10px;
border: 1px solid var(--border);
background: var(--surface2);
transition: all 0.15s;
user-select: none;
}
.status-item:hover { border-color: var(--border-bright); }
.status-item.active-status { border-color: var(--red); background: rgba(192,57,43,0.12); }
.status-check {
width: 14px; height: 14px;
border: 1px solid var(--border-bright);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem;
color: var(--red);
transition: all 0.15s;
}
.status-item.active-status .status-check { border-color: var(--red); background: rgba(192,57,43,0.2); }
.status-label {
font-family: var(--font-display);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
.status-item.active-status .status-label { color: var(--crisis); }
/* ── HP / MP / IP ────────────────────────────────── */
.vital-block {
display: flex;
align-items: center;
gap: 16px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 14px 16px;
margin-bottom: 10px;
}
.vital-block:last-child { margin-bottom: 0; }
.vital-label {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
width: 32px;
flex-shrink: 0;
}
.vital-label.hp { color: var(--red); }
.vital-label.mp { color: var(--teal); }
.vital-label.ip { color: var(--gold); }
.vital-inputs { display: flex; gap: 8px; flex: 1; align-items: center; }
.vital-inputs input { text-align: center; font-family: var(--font-mono); font-size: 1.1rem; }
.vital-sep { color: var(--text-dim); font-size: 0.8rem; flex-shrink: 0; }
.vital-formula {
font-size: 0.75rem;
color: var(--text-dim);
font-family: var(--font-mono);
}
.crisis-badge {
font-family: var(--font-display);
font-size: 0.5rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--crisis);
border: 1px solid var(--crisis);
padding: 2px 6px;
flex-shrink: 0;
}
/* ── LEVEL / XP ──────────────────────────────────── */
.level-display {
text-align: center;
padding: 20px;
background: var(--surface2);
border: 1px solid var(--border);
}
.level-num {
font-family: var(--font-display);
font-size: 3.5rem;
color: var(--gold);
line-height: 1;
display: block;
}
.level-text {
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.25em;
color: var(--text-dim);
text-transform: uppercase;
display: block;
margin-top: 4px;
}
.xp-bar-wrap {
margin-top: 14px;
background: var(--surface3);
border: 1px solid var(--border);
height: 8px;
position: relative;
overflow: hidden;
}
.xp-bar {
height: 100%;
background: linear-gradient(90deg, var(--gold-dim), var(--gold));
transition: width 0.5s ease;
}
.xp-label {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-dim);
display: flex;
justify-content: space-between;
margin-top: 5px;
}
/* ── FABULA POINTS ───────────────────────────────── */
.fp-pips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 10px;
}
.fp-pip {
width: 28px; height: 28px;
border: 2px solid var(--border-bright);
border-radius: 50%;
cursor: pointer;
transition: all 0.15s;
background: var(--surface2);
display: flex; align-items: center; justify-content: center;
}
.fp-pip.filled { background: var(--teal); border-color: var(--teal); box-shadow: 0 0 8px rgba(78,205,196,0.4); }
.fp-pip:hover { border-color: var(--teal); }
.fp-rules {
margin-top: 14px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.fp-rule {
font-size: 0.85rem;
color: var(--text-dim);
padding: 3px 0;
padding-left: 12px;
position: relative;
}
.fp-rule::before { content: '✦'; position: absolute; left: 0; font-size: 0.5rem; color: var(--gold); top: 5px; }
.fp-rule strong { color: var(--text); }
/* ── BONDS ───────────────────────────────────────── */
.bond-block {
border: 1px solid var(--border);
padding: 14px;
background: var(--surface2);
margin-bottom: 10px;
}
.bond-block:last-child { margin-bottom: 0; }
.bond-header { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.bond-num {
font-family: var(--font-display);
font-size: 0.6rem;
color: var(--gold);
border: 1px solid var(--gold-dim);
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.bond-header input { flex: 1; }
.bond-feelings {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
}
.bond-feeling {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-dim);
user-select: none;
}
.bond-feeling.active { color: var(--teal); }
.bond-feeling-box {
width: 12px; height: 12px;
border: 1px solid var(--border-bright);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.6rem;
transition: all 0.15s;
}
.bond-feeling.active .bond-feeling-box { background: var(--teal); border-color: var(--teal); color: #000; }
/* ── EQUIPMENT ───────────────────────────────────── */
.equip-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0;
border-bottom: 1px solid var(--border);
}
.equip-row:last-child { border-bottom: none; }
.equip-slot {
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--gold);
background: var(--surface3);
padding: 10px 12px;
border-right: 1px solid var(--border);
display: flex;
align-items: center;
}
.equip-fields { display: flex; flex-direction: column; gap: 0; }
.equip-fields input {
border: none;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
padding: 7px 10px;
}
.equip-fields input:last-child { border-bottom: none; }
.equip-fields input::placeholder { color: var(--border-bright); font-style: italic; }
/* ── MARTIAL CHECKBOXES ──────────────────────────── */
.martial-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-top: 10px;
}
.martial-item {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.martial-box {
width: 14px; height: 14px;
border: 1px solid var(--border-bright);
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem;
color: var(--teal);
transition: all 0.15s;
}
.martial-item.checked .martial-box { background: var(--teal-dim); border-color: var(--teal); }
.martial-item span {
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-dim);
}
/* ── CLASSES ─────────────────────────────────────── */
.class-block {
border: 1px solid var(--border);
background: var(--surface2);
margin-bottom: 12px;
}
.class-block:last-child { margin-bottom: 0; }
.class-header {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border-bottom: 1px solid var(--border);
}
.class-header input {
border: none;
border-right: 1px solid var(--border);
font-size: 0.9rem;
}
.class-header input:last-child { border-right: none; }
.class-skills { padding: 0; }
.class-skills textarea {
border: none;
font-size: 0.85rem;
min-height: 80px;
background: transparent;
}
/* ── SPELLS TABLE ────────────────────────────────── */
.spells-table { width: 100%; border-collapse: collapse; }
.spells-table thead tr {
background: var(--surface3);
}
.spells-table th {
font-family: var(--font-display);
font-size: 0.52rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--teal);
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border-bright);
}
.spells-table td { border-bottom: 1px solid var(--border); vertical-align: top; }
.spells-table td input, .spells-table td textarea {
border: none;
font-size: 0.9rem;
min-height: 32px;
padding: 6px 8px;
}
.spells-table td textarea { min-height: 55px; }
.spell-name-col { width: 35%; }
.spell-mp-col { width: 12%; }
.spell-targets-col { width: 18%; }
.spell-dur-col { width: 18%; }
.spell-del-col { width: 40px; }
.spell-del-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 6px 8px;
font-size: 0.9rem;
transition: color 0.15s;
}
.spell-del-btn:hover { color: var(--red); }
.add-btn {
margin-top: 8px;
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--teal);
background: transparent;
border: 1px solid var(--teal-dim);
padding: 6px 14px;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.add-btn:hover { background: var(--teal-dim); color: var(--text-bright); }
/* ── DISCIPLINES ─────────────────────────────────── */
.disciplines-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.disc-item {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.disc-box {
width: 13px; height: 13px;
border: 1px solid var(--border-bright);
display: flex; align-items: center; justify-content: center;
font-size: 0.6rem;
color: var(--gold);
transition: all 0.15s;
}
.disc-item.checked .disc-box { background: var(--gold-dim); border-color: var(--gold); }
.disc-item span {
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.disc-item.checked span { color: var(--gold); }
/* ── SAVE / LOAD ─────────────────────────────────── */
.toolbar {
display: flex;
gap: 8px;
align-items: center;
}
.btn-save, .btn-load {
font-family: var(--font-display);
font-size: 0.55rem;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 7px 16px;
cursor: pointer;
border: 1px solid var(--gold-dim);
background: transparent;
color: var(--gold);
transition: all 0.2s;
}
.btn-save:hover { background: var(--gold-dim); color: var(--text-bright); }
.btn-load { border-color: var(--border-bright); color: var(--text-dim); }
.btn-load:hover { border-color: var(--teal-dim); color: var(--teal); }
.btn-export { border-color: var(--teal-dim); color: var(--teal); }
.btn-export:hover { background: var(--teal-dim); color: var(--text-bright); }
.btn-import { border-color: var(--border-bright); color: var(--text-dim); }
.btn-import:hover { border-color: var(--gold-dim); color: var(--gold); }
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.save-status {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--teal);
opacity: 0;
transition: opacity 0.3s;
}
.save-status.show { opacity: 1; }
/* ── ORNAMENTS ───────────────────────────────────── */
.ornament {
text-align: center;
color: var(--gold-dim);
font-size: 0.8rem;
letter-spacing: 0.5em;
margin: 8px 0;
}
.def-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.def-block {
background: var(--surface2);
border: 1px solid var(--border);
padding: 10px 12px;
text-align: center;
}
.def-block label { display: block; margin-bottom: 6px; }
.def-block input { text-align: center; font-family: var(--font-mono); font-size: 1.2rem; font-weight: 600; }
/* ── RESPONSIVE ──────────────────────────────────── */
@media (max-width: 900px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.col-span-2 { grid-column: span 1; }
header { flex-direction: column; gap: 12px; }
.tabs { flex-wrap: wrap; }
}
/* ── TOOLTIP ─────────────────────────────────────── */
[title] { cursor: help; }
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-bright); }
::-webkit-scrollbar-thumb:hover { background: var(--teal-dim); }
</style>
</head>
<body>
<header>
<div class="logo">Fabula Ultima — Character Sheet</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>
</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() {
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);
}
// Auto-save every 30s
setInterval(saveSheet, 30000);
init();
</script>
</body>
</html>