Replace verbose key names with short identifiers in collectData/applyData (e.g. name→n, zenit→z, heroicSkills→hs). Nested bond, class, and spell objects are mapped at the boundary so in-memory state and rendering code are unchanged. applyData uses ?? fallbacks to remain compatible with data saved under the old key names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
460 lines
20 KiB
JavaScript
460 lines
20 KiB
JavaScript
// ── 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 = `<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 ────────────────────────────────────
|
|
// 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();
|