Files
TheriapolisV3/_design_handoff/character_creation/from_design/src/app.jsx
T

231 lines
9.4 KiB
React
Raw Normal View History

/* Main app shell — wizard navigation, state, aside summary. */
const { useState: useS, useEffect: useE, useMemo: useM } = React;
const STEPS = [
{ id: "clade", name: "Clade", key: "cladeId" },
{ id: "species", name: "Species", key: "speciesId" },
{ id: "class", name: "Calling", key: "classId" },
{ id: "background", name: "History", key: "backgroundId" },
{ id: "stats", name: "Abilities", key: "stats" },
{ id: "skills", name: "Skills", key: "skills" },
{ id: "review", name: "Sign", key: "name" },
];
const App = ({ data, tweaks, setTweaks }) => {
const [step, setStep] = useS(0);
const [state, setState] = useS(() => ({
data,
cladeId: "canidae",
speciesId: data.species.find(s => s.clade_id === "canidae")?.id,
classId: "fangsworn",
backgroundId: "pack_raised",
statMethod: "array",
statPool: STANDARD_ARRAY.map(v => ({ value: v })),
statAssign: {},
statHistory: [],
chosenSkills: [],
name: "",
portraitStyle: "silhouette",
}));
const set = (patch) => setState(s => ({ ...s, ...patch }));
const clade = data.clades.find(c => c.id === state.cladeId);
const species = data.species.find(s => s.id === state.speciesId);
const cls = data.classes.find(c => c.id === state.classId);
const bg = data.backgrounds.find(b => b.id === state.backgroundId);
// When class changes, reset skill picks
useE(() => { set({ chosenSkills: [] }); }, [state.classId]);
// When clade changes, ensure species belongs to it
useE(() => {
if (!species || species.clade_id !== state.cladeId) {
set({ speciesId: data.species.find(s => s.clade_id === state.cladeId)?.id });
}
}, [state.cladeId]);
// Validation per step
const validate = (i) => {
if (i === 0) return state.cladeId ? null : "Pick a clade.";
if (i === 1) return state.speciesId ? null : "Pick a species.";
if (i === 2) return state.classId ? null : "Pick a calling.";
if (i === 3) return state.backgroundId ? null : "Pick a background.";
if (i === 4) return Object.keys(state.statAssign).length === 6 ? null : `Assign all six abilities (${Object.keys(state.statAssign).length}/6).`;
if (i === 5) return state.chosenSkills.length === (cls?.skills_choose || 0) ? null : `Pick exactly ${cls?.skills_choose} skill${cls?.skills_choose>1?"s":""} (${state.chosenSkills.length}/${cls?.skills_choose}).`;
if (i === 6) return state.name.trim() ? null : "Enter a name.";
return null;
};
const stepError = validate(step);
const allValid = STEPS.every((_, i) => !validate(i));
const StepComp = [
Steps.StepClade, Steps.StepSpecies, Steps.StepClass, Steps.StepBackground,
Steps.StepStats, Steps.StepSkills, Steps.StepReview,
][step];
return (
<div className="app-frame">
<div className="codex-header">
<div>
<h1 className="codex-title">Theriapolis · <em>Codex of Becoming</em></h1>
<div className="codex-sub" style={{marginTop: 6}}>Folio {romanize(step+1)} of VII {STEPS[step].name}</div>
</div>
<div className="codex-sub" style={{textAlign: "right"}}>
Seed · 0x4F2A · Phase V · M2
</div>
</div>
<div className="stepper">
{STEPS.map((s, i) => {
// A step is locked if any earlier step has unmet requirements.
// The current step is always reachable; earlier steps are always
// reachable (so the user can go back and edit).
const firstIncomplete = STEPS.findIndex((_, j) => validate(j));
const locked = i > step && firstIncomplete !== -1 && firstIncomplete < i;
const isComplete = !validate(i) && i !== step;
return (
<div
key={s.id}
className={"step" + (i === step ? " active" : "") + (isComplete ? " complete" : "") + (locked ? " locked" : "")}
onClick={() => { if (!locked) setStep(i); }}
title={locked ? "Complete earlier folios first" : undefined}
>
<div className="num">{locked ? "✕" : romanize(i+1)}</div>
<div className="name">{s.name}</div>
</div>
);
})}
</div>
<div className="page">
<div className="page-main">
<StepComp state={state} set={set} tweaks={tweaks} goTo={setStep} />
</div>
<div className="page-aside">
<Aside state={state} set={set} tweaks={tweaks} setTweaks={setTweaks} />
</div>
</div>
<div className="nav-bar">
<button className="btn ghost" disabled={step === 0} onClick={() => setStep(Math.max(0, step-1))}> Back</button>
<div style={{display: "flex", alignItems: "center", gap: 24}}>
<div className={"validation" + (stepError ? "" : " ok")}>
{stepError || (step < 6 ? "Folio complete" : (allValid ? "Ready to sign" : "Some folios remain"))}
</div>
<div className="nav-progress">{step+1} / 7</div>
</div>
{step < STEPS.length - 1 ? (
<button className="btn primary" disabled={!!stepError} onClick={() => setStep(step+1)}>Next </button>
) : (
<button className="btn primary" disabled={!allValid} onClick={() => alert(`${state.name} steps into Theriapolis. (Confirmed.)`)}>Confirm & Begin</button>
)}
</div>
</div>
);
};
const Aside = ({ state, set, tweaks, setTweaks }) => {
const clade = state.data.clades.find(c => c.id === state.cladeId);
const species = state.data.species.find(s => s.id === state.speciesId);
const cls = state.data.classes.find(c => c.id === state.classId);
const bg = state.data.backgrounds.find(b => b.id === state.backgroundId);
return (
<div>
<div className="codex-sub" style={{marginBottom: 10}}>The Subject</div>
<Portrait clade={clade} species={species} style="placeholder" name={state.name} />
<div className="summary-block">
<h4>Name</h4>
<div className={"v" + (!state.name ? " empty" : "")}>{state.name || "Unnamed"}</div>
</div>
<div className="summary-block">
<h4>Lineage</h4>
<div className="v">{species?.name || "—"} <small>{clade?.name} · {SIZE_LABEL[species?.size] || "—"}</small></div>
{(clade || species) && (
<div className="trait-chips" style={{marginTop: 8}}>
{(clade?.traits || []).map(t => (
<TraitName key={"c-"+t.id} trait={t} suffix="" />
))}
{(species?.traits || []).map(t => (
<TraitName key={"s-"+t.id} trait={t} suffix="" />
))}
{tweaks.showDetriments && (clade?.detriments || []).map(t => (
<TraitName key={"cd-"+t.id} trait={t} detriment suffix="" />
))}
{tweaks.showDetriments && (species?.detriments || []).map(t => (
<TraitName key={"sd-"+t.id} trait={t} detriment suffix="" />
))}
</div>
)}
</div>
<div className="summary-block">
<h4>Calling & History</h4>
<div className="v">{cls?.name || "—"} <small>d{cls?.hit_die} · {bg?.name || "no history"}</small></div>
{cls && (
<div className="trait-chips" style={{marginTop: 8}}>
{(cls.level_table?.find(l => l.level === 1)?.features || [])
.filter(k => !["asi","subclass_select","subclass_feature"].includes(k))
.map(k => {
const f = cls.feature_definitions[k];
if (!f) return null;
return <TraitName key={"cls-"+k} trait={{ id: cls.id+"_"+k, name: f.name, description: f.description, tag: f.kind }} suffix="" />;
})}
</div>
)}
{bg && (
<div className="trait-chips" style={{marginTop: 6}}>
<TraitName trait={{ id: bg.id+"_feat", name: bg.feature_name, description: bg.feature_description, tag: "feature" }} suffix="" />
</div>
)}
</div>
<div className="summary-block">
<h4>Abilities</h4>
<div className="stat-strip">
{ABILITIES.map(ab => {
const base = state.statAssign[ab];
const cm = clade?.ability_mods[ab] || 0;
const sm = species?.ability_mods[ab] || 0;
const f = base != null ? base + cm + sm : null;
const m = f != null ? abilityMod(f) : null;
return (
<div className="cell" key={ab}>
<div className="ab">{ab}</div>
<div className="sc">{f ?? "—"}</div>
<div className={"md" + (m != null && m < 0 ? " neg" : "")}>{m != null ? signed(m) : ""}</div>
</div>
);
})}
</div>
</div>
<div className="summary-block">
<h4>Skills · {state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)}</h4>
<div className="trait-chips aside-skills">
{(bg?.skill_proficiencies || []).map(s => (
<SkillChip key={"bg-"+s} id={s} className="from-bg" />
))}
{state.chosenSkills.map(s => (
<SkillChip key={"cls-"+s} id={s} className="from-cls" />
))}
{(state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)) === 0 && (
<span style={{fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-mute)", letterSpacing: "0.18em"}}>none yet</span>
)}
</div>
</div>
</div>
);
};
function romanize(n) {
return ["I","II","III","IV","V","VI","VII","VIII","IX","X"][n-1] || String(n);
}
window.App = App;