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>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
/* 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;
|
||||
Reference in New Issue
Block a user