Files
TheriapolisV3/_design_handoff/character_creation/from_design/src/app.jsx
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

231 lines
9.4 KiB
React
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.
/* 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;