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

366 lines
16 KiB
JavaScript

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