|
|
|
|
@@ -25,7 +25,71 @@ const DISCIPLINES = [
|
|
|
|
|
"Spiritism",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const BLANK_FIELDS = {
|
|
|
|
|
interface Fields {
|
|
|
|
|
charName: string;
|
|
|
|
|
charPronouns: string;
|
|
|
|
|
charIdentity: string;
|
|
|
|
|
charTheme: string;
|
|
|
|
|
charOrigin: string;
|
|
|
|
|
charTraits: string;
|
|
|
|
|
xpCurrent: string | number;
|
|
|
|
|
zenit: string | number;
|
|
|
|
|
initMod: string | number;
|
|
|
|
|
defense: string | number;
|
|
|
|
|
magDef: string | number;
|
|
|
|
|
dexBase: string | number;
|
|
|
|
|
dexCur: string | number;
|
|
|
|
|
insBase: string | number;
|
|
|
|
|
insCur: string | number;
|
|
|
|
|
migBase: string | number;
|
|
|
|
|
migCur: string | number;
|
|
|
|
|
wlpBase: string | number;
|
|
|
|
|
wlpCur: string | number;
|
|
|
|
|
hpMax: string | number;
|
|
|
|
|
hpCur: string | number;
|
|
|
|
|
mpMax: string | number;
|
|
|
|
|
mpCur: string | number;
|
|
|
|
|
ipMax: string | number;
|
|
|
|
|
ipCur: string | number;
|
|
|
|
|
backpack: string;
|
|
|
|
|
heroicSkills: string;
|
|
|
|
|
ritualsNotes: string;
|
|
|
|
|
accName: string;
|
|
|
|
|
accDesc: string;
|
|
|
|
|
armName: string;
|
|
|
|
|
armDesc: string;
|
|
|
|
|
mhName: string;
|
|
|
|
|
mhDesc: string;
|
|
|
|
|
ohName: string;
|
|
|
|
|
ohDesc: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Bond {
|
|
|
|
|
name: string;
|
|
|
|
|
feelings: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ClassEntry {
|
|
|
|
|
name: string;
|
|
|
|
|
benefits: string;
|
|
|
|
|
skills: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Spell {
|
|
|
|
|
name: string;
|
|
|
|
|
notes: string;
|
|
|
|
|
mp: string | number;
|
|
|
|
|
targets: string;
|
|
|
|
|
duration: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CheckMap = Record<string, boolean>;
|
|
|
|
|
|
|
|
|
|
// Saved/shared payloads use abbreviated keys (with legacy long-name
|
|
|
|
|
// fallbacks), so the shape is intentionally loose.
|
|
|
|
|
type SavedData = Record<string, any>;
|
|
|
|
|
|
|
|
|
|
const BLANK_FIELDS: Fields = {
|
|
|
|
|
charName: "",
|
|
|
|
|
charPronouns: "",
|
|
|
|
|
charIdentity: "",
|
|
|
|
|
@@ -64,12 +128,12 @@ const BLANK_FIELDS = {
|
|
|
|
|
ohDesc: "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const BLANK_BONDS = Array.from({ length: 6 }, () => ({
|
|
|
|
|
const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
|
|
|
|
|
name: "",
|
|
|
|
|
feelings: [],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
async function compressToBase64(str) {
|
|
|
|
|
async function compressToBase64(str: string) {
|
|
|
|
|
const stream = new CompressionStream("deflate-raw");
|
|
|
|
|
const writer = stream.writable.getWriter();
|
|
|
|
|
writer.write(new TextEncoder().encode(str));
|
|
|
|
|
@@ -82,7 +146,7 @@ async function compressToBase64(str) {
|
|
|
|
|
return btoa(binary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decompressFromBase64(b64) {
|
|
|
|
|
async function decompressFromBase64(b64: string) {
|
|
|
|
|
try {
|
|
|
|
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
|
|
|
const stream = new DecompressionStream("deflate-raw");
|
|
|
|
|
@@ -103,15 +167,15 @@ export default function CharacterSheet() {
|
|
|
|
|
const [copyStatus, setCopyStatus] = useState(false);
|
|
|
|
|
const [level, setLevel] = useState(1);
|
|
|
|
|
const [fp, setFp] = useState(0);
|
|
|
|
|
const [fields, setFields] = useState(BLANK_FIELDS);
|
|
|
|
|
const [bonds, setBonds] = useState(BLANK_BONDS);
|
|
|
|
|
const [primaryClasses, setPrimaryClasses] = useState([]);
|
|
|
|
|
const [otherClasses, setOtherClasses] = useState([]);
|
|
|
|
|
const [spells, setSpells] = useState([]);
|
|
|
|
|
const [statuses, setStatuses] = useState({});
|
|
|
|
|
const [martial, setMartial] = useState({});
|
|
|
|
|
const [disciplines, setDisciplines] = useState({});
|
|
|
|
|
const [theme, setTheme] = useState(
|
|
|
|
|
const [fields, setFields] = useState<Fields>(BLANK_FIELDS);
|
|
|
|
|
const [bonds, setBonds] = useState<Bond[]>(BLANK_BONDS);
|
|
|
|
|
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
|
|
|
|
|
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
|
|
|
|
|
const [spells, setSpells] = useState<Spell[]>([]);
|
|
|
|
|
const [statuses, setStatuses] = useState<CheckMap>({});
|
|
|
|
|
const [martial, setMartial] = useState<CheckMap>({});
|
|
|
|
|
const [disciplines, setDisciplines] = useState<CheckMap>({});
|
|
|
|
|
const [theme, setTheme] = useState<string>(
|
|
|
|
|
() =>
|
|
|
|
|
localStorage.getItem("fabulaUltimaTheme") ||
|
|
|
|
|
(window.matchMedia("(prefers-color-scheme: light)").matches
|
|
|
|
|
@@ -119,7 +183,7 @@ export default function CharacterSheet() {
|
|
|
|
|
: "dark"),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const importFileRef = useRef(null);
|
|
|
|
|
const importFileRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
document.documentElement.dataset.theme = theme;
|
|
|
|
|
@@ -127,14 +191,15 @@ export default function CharacterSheet() {
|
|
|
|
|
}, [theme]);
|
|
|
|
|
|
|
|
|
|
const f = useCallback(
|
|
|
|
|
(key, val) => setFields((prev) => ({ ...prev, [key]: val })),
|
|
|
|
|
<K extends keyof Fields>(key: K, val: Fields[K]) =>
|
|
|
|
|
setFields((prev) => ({ ...prev, [key]: val })),
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const xp = parseInt(fields.xpCurrent) || 0;
|
|
|
|
|
const xp = parseInt(String(fields.xpCurrent)) || 0;
|
|
|
|
|
const xpPct = Math.min((xp % 10) * 10, 100);
|
|
|
|
|
const hpMax = parseInt(fields.hpMax) || 0;
|
|
|
|
|
const hpCur = parseInt(fields.hpCur) || 0;
|
|
|
|
|
const hpMax = parseInt(String(fields.hpMax)) || 0;
|
|
|
|
|
const hpCur = parseInt(String(fields.hpCur)) || 0;
|
|
|
|
|
const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2);
|
|
|
|
|
const fpTotal = Math.max(10, fp);
|
|
|
|
|
|
|
|
|
|
@@ -210,7 +275,7 @@ export default function CharacterSheet() {
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const applyData = useCallback((d) => {
|
|
|
|
|
const applyData = useCallback((d: SavedData) => {
|
|
|
|
|
setFields({
|
|
|
|
|
charName: d.n ?? d.name ?? "",
|
|
|
|
|
charPronouns: d.pn ?? d.pronouns ?? "",
|
|
|
|
|
@@ -266,7 +331,7 @@ export default function CharacterSheet() {
|
|
|
|
|
const rawBonds = d.bo ?? d.bonds;
|
|
|
|
|
if (rawBonds)
|
|
|
|
|
setBonds(
|
|
|
|
|
rawBonds.map((b) => ({
|
|
|
|
|
rawBonds.map((b: SavedData) => ({
|
|
|
|
|
name: b.n ?? b.name ?? "",
|
|
|
|
|
feelings: b.f ?? b.feelings ?? [],
|
|
|
|
|
})),
|
|
|
|
|
@@ -275,7 +340,7 @@ export default function CharacterSheet() {
|
|
|
|
|
const rawPrimary = d.pc ?? d.primaryClasses;
|
|
|
|
|
if (rawPrimary)
|
|
|
|
|
setPrimaryClasses(
|
|
|
|
|
rawPrimary.map((c) => ({
|
|
|
|
|
rawPrimary.map((c: SavedData) => ({
|
|
|
|
|
name: c.n ?? c.name ?? "",
|
|
|
|
|
benefits: c.b ?? c.benefits ?? "",
|
|
|
|
|
skills: c.s ?? c.skills ?? "",
|
|
|
|
|
@@ -285,7 +350,7 @@ export default function CharacterSheet() {
|
|
|
|
|
const rawOther = d.oc ?? d.otherClasses;
|
|
|
|
|
if (rawOther)
|
|
|
|
|
setOtherClasses(
|
|
|
|
|
rawOther.map((c) => ({
|
|
|
|
|
rawOther.map((c: SavedData) => ({
|
|
|
|
|
name: c.n ?? c.name ?? "",
|
|
|
|
|
benefits: c.b ?? c.benefits ?? "",
|
|
|
|
|
skills: c.s ?? c.skills ?? "",
|
|
|
|
|
@@ -295,7 +360,7 @@ export default function CharacterSheet() {
|
|
|
|
|
const rawSpells = d.sp ?? d.spells;
|
|
|
|
|
if (rawSpells)
|
|
|
|
|
setSpells(
|
|
|
|
|
rawSpells.map((s) => ({
|
|
|
|
|
rawSpells.map((s: SavedData) => ({
|
|
|
|
|
name: s.n ?? s.name ?? "",
|
|
|
|
|
notes: s.nt ?? s.notes ?? "",
|
|
|
|
|
mp: s.mp ?? "",
|
|
|
|
|
@@ -369,13 +434,13 @@ export default function CharacterSheet() {
|
|
|
|
|
}, [collectData]);
|
|
|
|
|
|
|
|
|
|
const handleImportFile = useCallback(
|
|
|
|
|
(e) => {
|
|
|
|
|
const file = e.target.files[0];
|
|
|
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = (ev) => {
|
|
|
|
|
try {
|
|
|
|
|
applyData(JSON.parse(ev.target.result));
|
|
|
|
|
applyData(JSON.parse(ev.target?.result as string));
|
|
|
|
|
saveSheet();
|
|
|
|
|
} catch {
|
|
|
|
|
alert("Could not import: invalid JSON file.");
|
|
|
|
|
@@ -399,25 +464,25 @@ export default function CharacterSheet() {
|
|
|
|
|
}, [collectData]);
|
|
|
|
|
|
|
|
|
|
const calcHP = useCallback(() => {
|
|
|
|
|
const mig = parseInt(fields.migBase) || 6;
|
|
|
|
|
const mig = parseInt(String(fields.migBase)) || 6;
|
|
|
|
|
const max = mig * 5 + level;
|
|
|
|
|
setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max }));
|
|
|
|
|
}, [fields.migBase, level]);
|
|
|
|
|
|
|
|
|
|
const calcMP = useCallback(() => {
|
|
|
|
|
const wlp = parseInt(fields.wlpBase) || 6;
|
|
|
|
|
const wlp = parseInt(String(fields.wlpBase)) || 6;
|
|
|
|
|
const max = wlp * 5 + level;
|
|
|
|
|
setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max }));
|
|
|
|
|
}, [fields.wlpBase, level]);
|
|
|
|
|
|
|
|
|
|
const toggleStatus = (s) =>
|
|
|
|
|
const toggleStatus = (s: string) =>
|
|
|
|
|
setStatuses((prev) => ({ ...prev, [s]: !prev[s] }));
|
|
|
|
|
const toggleMartial = (m) =>
|
|
|
|
|
const toggleMartial = (m: string) =>
|
|
|
|
|
setMartial((prev) => ({ ...prev, [m]: !prev[m] }));
|
|
|
|
|
const toggleDisc = (d) =>
|
|
|
|
|
const toggleDisc = (d: string) =>
|
|
|
|
|
setDisciplines((prev) => ({ ...prev, [d]: !prev[d] }));
|
|
|
|
|
|
|
|
|
|
const toggleFeeling = (bondIdx, feeling) => {
|
|
|
|
|
const toggleFeeling = (bondIdx: number, feeling: string) => {
|
|
|
|
|
setBonds((prev) =>
|
|
|
|
|
prev.map((b, i) => {
|
|
|
|
|
if (i !== bondIdx) return b;
|
|
|
|
|
@@ -647,12 +712,12 @@ export default function CharacterSheet() {
|
|
|
|
|
<span className="icon">◈</span> Attributes
|
|
|
|
|
</div>
|
|
|
|
|
<div className="attr-grid">
|
|
|
|
|
{[
|
|
|
|
|
{([
|
|
|
|
|
{ label: "Dexterity", base: "dexBase", cur: "dexCur" },
|
|
|
|
|
{ label: "Insight", base: "insBase", cur: "insCur" },
|
|
|
|
|
{ label: "Might", base: "migBase", cur: "migCur" },
|
|
|
|
|
{ label: "Willpower", base: "wlpBase", cur: "wlpCur" },
|
|
|
|
|
].map(({ label, base, cur }) => (
|
|
|
|
|
] as const).map(({ label, base, cur }) => (
|
|
|
|
|
<div key={label} className="attr-block">
|
|
|
|
|
<div className="attr-name">{label}</div>
|
|
|
|
|
<div className="attr-inputs">
|
|
|
|
|
@@ -917,7 +982,7 @@ export default function CharacterSheet() {
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ marginTop: 14 }}>
|
|
|
|
|
{[
|
|
|
|
|
{([
|
|
|
|
|
{
|
|
|
|
|
slot: "Accessory",
|
|
|
|
|
name: "accName",
|
|
|
|
|
@@ -946,7 +1011,7 @@ export default function CharacterSheet() {
|
|
|
|
|
namePh: "Weapon / shield",
|
|
|
|
|
descPh: "Damage / effect",
|
|
|
|
|
},
|
|
|
|
|
].map(({ slot, name, desc, namePh, descPh }) => (
|
|
|
|
|
] as const).map(({ slot, name, desc, namePh, descPh }) => (
|
|
|
|
|
<div key={slot} className="equip-row">
|
|
|
|
|
<div className="equip-slot">{slot}</div>
|
|
|
|
|
<div className="equip-fields">
|
|
|
|
|
@@ -1346,6 +1411,7 @@ export default function CharacterSheet() {
|
|
|
|
|
<button
|
|
|
|
|
className="btn-load btn-import btn-lg"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!importFileRef.current) return;
|
|
|
|
|
importFileRef.current.value = "";
|
|
|
|
|
importFileRef.current.click();
|
|
|
|
|
}}
|