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;
|
||||
@@ -0,0 +1,163 @@
|
||||
/* Data loader + helpers */
|
||||
|
||||
const ABILITIES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||||
const ABILITY_LABELS = {
|
||||
STR: "Strength", DEX: "Dexterity", CON: "Constitution",
|
||||
INT: "Intellect", WIS: "Wisdom", CHA: "Charisma",
|
||||
};
|
||||
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
|
||||
|
||||
const SKILL_ABILITY = {
|
||||
acrobatics: "DEX", animal_handling: "WIS", arcana: "INT",
|
||||
athletics: "STR", deception: "CHA", history: "INT",
|
||||
insight: "WIS", intimidation: "CHA", investigation: "INT",
|
||||
medicine: "WIS", nature: "INT", perception: "WIS",
|
||||
performance: "CHA", persuasion: "CHA", religion: "INT",
|
||||
sleight_of_hand: "DEX", stealth: "DEX", survival: "WIS",
|
||||
};
|
||||
|
||||
const SKILL_LABEL = {
|
||||
acrobatics: "Acrobatics", animal_handling: "Animal Handling", arcana: "Arcana",
|
||||
athletics: "Athletics", deception: "Deception", history: "History",
|
||||
insight: "Insight", intimidation: "Intimidation", investigation: "Investigation",
|
||||
medicine: "Medicine", nature: "Nature", perception: "Perception",
|
||||
performance: "Performance", persuasion: "Persuasion", religion: "Religion",
|
||||
sleight_of_hand: "Sleight of Hand", stealth: "Stealth", survival: "Survival",
|
||||
};
|
||||
|
||||
// Skill descriptions, framed in Theriapolis's clade-vocabulary so hover hints
|
||||
// match the rest of the codex's tone.
|
||||
const SKILL_DESC = {
|
||||
acrobatics: "Tumbling, balance, and the kind of footwork that keeps you upright on a coliseum sand-floor or a warren-rope. Body-cunning under pressure.",
|
||||
animal_handling: "Reading and steering non-sentient beasts — feral hounds, draft-kine, the wild cousins of your own clade. Calming, herding, riding.",
|
||||
arcana: "Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws.",
|
||||
athletics: "Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold.",
|
||||
deception: "Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true.",
|
||||
history: "The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt.",
|
||||
insight: "Reading another's true posture beneath their words. Catching the off-note in a snarl, the held breath, the lie in a friendly tail.",
|
||||
intimidation: "Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance.",
|
||||
investigation: "Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict.",
|
||||
medicine: "Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them.",
|
||||
nature: "Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant.",
|
||||
perception: "Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision.",
|
||||
performance: "Holding an audience — coliseum crowd, courtroom gallery, market square. Song, oratory, the body that compels watching.",
|
||||
persuasion: "Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement.",
|
||||
religion: "The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking.",
|
||||
sleight_of_hand: "Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike.",
|
||||
stealth: "Movement unseen and unsmelled. Wind-checking, scent-suppression, the slow weight-shift on a creaking floor.",
|
||||
survival: "Field-craft beyond the wall: tracking, foraging, fire-making, knowing which run-off is safe to drink and which carries the upstream butcher's leavings.",
|
||||
};
|
||||
|
||||
const SIZE_LABEL = {
|
||||
small: "Small", medium: "Medium", medium_large: "Medium-Large", large: "Large",
|
||||
};
|
||||
|
||||
// Class → recommended clades (informational, per design intent)
|
||||
const CLASS_CLADE_REC = {
|
||||
fangsworn: ["canidae", "felidae", "ursidae"],
|
||||
bulwark: ["bovidae", "ursidae"],
|
||||
feral: ["ursidae", "mustelidae", "bovidae"],
|
||||
shadow_pelt: ["felidae", "mustelidae", "leporidae"],
|
||||
scent_broker: ["canidae", "mustelidae"],
|
||||
covenant_keeper: ["canidae", "bovidae", "cervidae"],
|
||||
muzzle_speaker: ["felidae", "leporidae"],
|
||||
claw_wright: ["mustelidae", "leporidae"],
|
||||
};
|
||||
|
||||
// Language metadata — name + description for hover hints
|
||||
const LANGUAGES = {
|
||||
common: { name: "Common", description: "The market-and-courthouse trade tongue of Theriapolis. Spoken by every clade; the language of contracts, guard-watches, and most of the city's signage." },
|
||||
canid: { name: "Canid", description: "Pack-tongue of the Canidae. Heavy with subsonic registers and scent-words — Canid sentences carry pheromonal undertones non-Canid speakers cannot fully parse." },
|
||||
felid: { name: "Felid", description: "Sinuous and tonal, with a parallel tail-and-ear pidgin. Felid speakers trade in implication and pause; lying in Felid is a high art." },
|
||||
mustelid: { name: "Mustelid", description: "Quick, percussive trade-speech of the Mustelidae. Famous for its dense vocabulary of musks, debts, and small grievances." },
|
||||
ursid: { name: "Ursid", description: "Slow, low-register growl-speech. Ursid grammar prefers final emphasis — the important word always comes last." },
|
||||
cervid: { name: "Cervid", description: "Old, hymn-shaped tongue of the Cervidae. Most speakers know Cervid as a song-language for funerals, treaties, and the long calendar." },
|
||||
bovid: { name: "Bovid", description: "Patient, formal speech of the herd-clades. Bovid is the language of guild-councils and oaths; lying in formal Bovid is itself a punishable act." },
|
||||
leporid: { name: "Leporid", description: "Rapid, twitch-paced chatter of the Leporidae. Leporid uses tense markers for danger and runs faster than most non-Leporidae can follow." },
|
||||
};
|
||||
|
||||
// Pretty item names from item ids
|
||||
const ITEM_NAME = {
|
||||
rend_sword: "Rend-sword", chain_shirt: "Chain Shirt", buckler: "Buckler",
|
||||
healers_kit: "Healer's Kit", rations_predator: "Rations (predator)",
|
||||
rations_prey: "Rations (prey)", hoof_club: "Hoof Club", chain_mail: "Chain Mail",
|
||||
standard_shield: "Shield", paw_axe: "Paw Axe", hide_vest: "Hide Vest",
|
||||
thorn_blade: "Thorn-blade", studded_leather: "Studded Leather",
|
||||
claw_bow: "Claw-bow", poultice_universal: "Universal Poultice",
|
||||
scent_mask_basic: "Scent-mask", fang_knife: "Fang Knife",
|
||||
leather_harness: "Leather Harness", pheromone_vial_calm: "Pheromone Vial (calm)",
|
||||
pheromone_vial_fear: "Pheromone Vial (fear)", rope_claw_braid: "Claw-braid Rope",
|
||||
};
|
||||
|
||||
// Plain-language readings for traits/detriments — author-curated
|
||||
const TRAIT_READING = {
|
||||
pack_instinct: "When a friend nearby gets attacked, you can throw your shoulder in front of them to ward off the blow.",
|
||||
superior_scent: "Your nose tells you what eyes can't — feelings, lies, fear in a room.",
|
||||
subsonic_communication: "You can talk silently with other Canid-folk over short distances.",
|
||||
pack_dependent: "Alone, your nerves fray. Crowds steady you.",
|
||||
scent_overload: "Strong smells make a noisy room.",
|
||||
retractable_claws: "Sheath them for fine work, draw them for a fight.",
|
||||
darkvision: "Dim light reads as bright; pitch dark reads as dim grey.",
|
||||
feline_grace: "You shrug off most falls and find your feet on any ledge.",
|
||||
tail_speak: "Your tail says what your mouth won't — visible to anyone who reads Felid.",
|
||||
solitary_instinct: "Help from non-Felidae mostly bounces off — you work alone or you work with kin.",
|
||||
prides_cost: "A public fumble costs you the next charm check; the room is watching.",
|
||||
sinuous_frame: "Bend through gaps the size of a saucer; slip a grapple like water.",
|
||||
burning_metabolism: "Cold doesn't stick. Hunger does.",
|
||||
ferocity: "Wounded, you bite harder for one turn.",
|
||||
high_metabolism: "Two days of rations a day — or you crash.",
|
||||
scent_marker: "Mustelid musk is unmistakable. Stealth costs extra.",
|
||||
powerful_build: "You count as one size larger for hauling and grappling.",
|
||||
thick_hide: "Your skin is armor. Blunt damage barely lands.",
|
||||
bone_crushing_jaws: "A clean bite ends fights.",
|
||||
lumbering: "You don't sneak. You arrive.",
|
||||
heat_intolerance: "Heat saps you — long hot days demand rest.",
|
||||
fleet_footed: "You're faster, and dashing past enemies is safer.",
|
||||
antlers: "A natural weapon, growing back each year.",
|
||||
wide_field_of_view: "Hard to flank — you see what's at the corners.",
|
||||
flight_response: "Sudden danger triggers a save-or-flee reflex.",
|
||||
delicate_frame: "Less HP per level. You feel hits.",
|
||||
horns: "Permanent natural weapon — bone, not bone-spurs.",
|
||||
herd_wall: "Allies at your shoulder give you AC.",
|
||||
unshakeable: "Fear and charm slide off you.",
|
||||
ponderous_gait: "Slower base speed; quick pivots aren't your thing.",
|
||||
stubborn: "Feints fool you because you commit.",
|
||||
leaping_strides: "No run-up needed for big jumps.",
|
||||
burrow_savvy: "Underground is home. You see and survive there.",
|
||||
twitch_reflexes: "First in initiative; ranged shots flinch off.",
|
||||
fragile_body: "Less HP. Easier to knock down.",
|
||||
constant_vigilance: "New places keep you wired — short rests don't take.",
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
const fetchJson = async (p) => {
|
||||
const r = await fetch(p);
|
||||
if (!r.ok) throw new Error("Failed to load " + p);
|
||||
return r.json();
|
||||
};
|
||||
const [clades, species, classes, backgrounds] = await Promise.all([
|
||||
fetchJson("data/clades.json"),
|
||||
fetchJson("data/species.json"),
|
||||
fetchJson("data/classes.json"),
|
||||
fetchJson("data/backgrounds.json"),
|
||||
]);
|
||||
return { clades, species, classes, backgrounds };
|
||||
}
|
||||
|
||||
function abilityMod(score) { return Math.floor((score - 10) / 2); }
|
||||
function signed(n) { return n >= 0 ? `+${n}` : `${n}`; }
|
||||
|
||||
window.ABILITIES = ABILITIES;
|
||||
window.ABILITY_LABELS = ABILITY_LABELS;
|
||||
window.STANDARD_ARRAY = STANDARD_ARRAY;
|
||||
window.SKILL_ABILITY = SKILL_ABILITY;
|
||||
window.SKILL_LABEL = SKILL_LABEL;
|
||||
window.SKILL_DESC = SKILL_DESC;
|
||||
window.SIZE_LABEL = SIZE_LABEL;
|
||||
window.CLASS_CLADE_REC = CLASS_CLADE_REC;
|
||||
window.ITEM_NAME = ITEM_NAME;
|
||||
window.LANGUAGES = LANGUAGES;
|
||||
window.TRAIT_READING = TRAIT_READING;
|
||||
window.loadData = loadData;
|
||||
window.abilityMod = abilityMod;
|
||||
window.signed = signed;
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Entry point — loads data, mounts app. */
|
||||
|
||||
const Root = () => {
|
||||
const [data, setData] = React.useState(null);
|
||||
const [err, setErr] = React.useState(null);
|
||||
const [tweaks, setTweak] = useTweaks(window.TweakDefaults);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData().then(setData).catch(e => setErr(String(e)));
|
||||
}, []);
|
||||
|
||||
// setTweaks accepts a partial object: {theme: 'dark', density: 'compact'}
|
||||
const setTweaks = (patch) => {
|
||||
Object.entries(patch).forEach(([k, v]) => setTweak(k, v));
|
||||
};
|
||||
|
||||
if (err) return <div style={{padding: 40, color: "var(--seal)", fontFamily: "var(--serif-display)"}}>Failed to load codex: {err}</div>;
|
||||
if (!data) return (
|
||||
<div style={{padding: 80, textAlign: "center", fontFamily: "var(--serif-display)", fontStyle: "italic", color: "var(--ink-mute)"}}>
|
||||
Unsealing the codex…
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<App data={data} tweaks={tweaks} setTweaks={setTweaks} />
|
||||
<TweaksWiring tweaks={tweaks} setTweaks={setTweaks} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("app")).render(<Root />);
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Portrait — three swappable styles: silhouette+aura, sigil+heraldry, placeholder slot. */
|
||||
|
||||
const Portrait = ({ clade, species, style, name }) => {
|
||||
const cladeId = clade?.id || "canidae";
|
||||
const speciesId = species?.id;
|
||||
|
||||
const cladeColor = {
|
||||
canidae: "#8b6a3a", felidae: "#c08a3a", mustelidae: "#74552c",
|
||||
ursidae: "#5a3a1c", cervidae: "#a07840", bovidae: "#6a4a2a", leporidae: "#b89863",
|
||||
}[cladeId] || "#8b6a3a";
|
||||
|
||||
if (style === "silhouette") {
|
||||
return (
|
||||
<div className="portrait-frame" style={{position: "relative"}}>
|
||||
<SilhouetteSVG cladeId={cladeId} speciesId={speciesId} accent={cladeColor} />
|
||||
<div style={{position: "absolute", bottom: 12, left: 12, right: 12, fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.2em", textTransform: "uppercase", color: "var(--ink-mute)", textAlign: "center"}}>
|
||||
{species?.name || "—"} · {clade?.name || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === "heraldry") {
|
||||
return (
|
||||
<div className="portrait-frame" style={{position: "relative"}}>
|
||||
<svg viewBox="0 0 100 110" style={{width: "85%", height: "auto"}}>
|
||||
{/* Heraldic shield */}
|
||||
<defs>
|
||||
<linearGradient id="hg" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor="rgba(255,250,235,0.16)" />
|
||||
<stop offset="1" stopColor="rgba(0,0,0,0.18)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M10 15 L90 15 L90 60 Q90 95 50 105 Q10 95 10 60 Z" fill={cladeColor} stroke="var(--ink)" strokeWidth="0.8" opacity="0.9"/>
|
||||
<path d="M10 15 L90 15 L90 60 Q90 95 50 105 Q10 95 10 60 Z" fill="url(#hg)" />
|
||||
{/* Quarter divisions */}
|
||||
<path d="M50 15 V105 M10 55 H90" stroke="var(--ink)" strokeWidth="0.4" opacity="0.5" />
|
||||
{/* Center sigil */}
|
||||
<g transform="translate(34, 38) scale(1.0)" style={{color: "var(--bg)"}}>
|
||||
<Sigil id={cladeId} size={32}/>
|
||||
</g>
|
||||
</svg>
|
||||
<div style={{position: "absolute", bottom: 12, left: 12, right: 12, fontFamily: "var(--serif-display)", fontSize: 14, fontStyle: "italic", color: "var(--ink-soft)", textAlign: "center"}}>
|
||||
House of {clade?.name || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// placeholder
|
||||
return (
|
||||
<div className="portrait-frame">
|
||||
<div style={{
|
||||
width: "82%", aspectRatio: "1 / 1",
|
||||
border: "1.5px dashed var(--rule)",
|
||||
display: "grid", placeItems: "center", textAlign: "center",
|
||||
fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.2em",
|
||||
textTransform: "uppercase", color: "var(--ink-mute)",
|
||||
background: "repeating-linear-gradient(45deg, transparent 0 8px, rgba(0,0,0,0.04) 8px 9px)",
|
||||
}}>
|
||||
<div>
|
||||
<div style={{fontFamily: "var(--serif-display)", fontSize: 24, color: "var(--ink-soft)", marginBottom: 6, textTransform: "none", letterSpacing: 0, fontStyle: "italic"}}>portrait</div>
|
||||
{clade?.name || "—"} / {species?.name || "—"}
|
||||
<div style={{marginTop: 4, opacity: 0.7}}>tbd · art ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SilhouetteSVG = ({ cladeId, speciesId, accent }) => {
|
||||
// Stylized side-profile silhouette per clade family.
|
||||
// Each ~ figurative anthro-bust suggested through tonal shapes only.
|
||||
const head = SILHOUETTES[cladeId] || SILHOUETTES.canidae;
|
||||
return (
|
||||
<svg viewBox="0 0 100 110" style={{width: "85%", height: "auto"}}>
|
||||
<defs>
|
||||
<radialGradient id={"aura-"+cladeId} cx="0.5" cy="0.4" r="0.7">
|
||||
<stop offset="0" stopColor={accent} stopOpacity="0.35" />
|
||||
<stop offset="0.6" stopColor={accent} stopOpacity="0.08" />
|
||||
<stop offset="1" stopColor={accent} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id={"sil-"+cladeId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="var(--ink)" />
|
||||
<stop offset="1" stopColor="var(--ink-soft)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* aura */}
|
||||
<circle cx="50" cy="55" r="48" fill={"url(#aura-"+cladeId+")"} />
|
||||
{/* silhouette */}
|
||||
<g fill={"url(#sil-"+cladeId+")"}>{head}</g>
|
||||
{/* scent particles */}
|
||||
<g fill={accent} opacity="0.5">
|
||||
<circle cx="22" cy="40" r="0.9"><animate attributeName="cy" values="40;28;40" dur="4s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.5;0;0.5" dur="4s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="78" cy="46" r="1.1"><animate attributeName="cy" values="46;30;46" dur="5s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.4;0;0.4" dur="5s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="50" cy="22" r="0.7"><animate attributeName="cy" values="22;6;22" dur="6s" repeatCount="indefinite" /><animate attributeName="opacity" values="0.5;0;0.5" dur="6s" repeatCount="indefinite" /></circle>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Hand-shaped silhouette paths per clade — abstract anthropomorphic busts
|
||||
const SILHOUETTES = {
|
||||
canidae: <>
|
||||
{/* shoulders */}
|
||||
<path d="M20 105 Q22 80 35 75 L65 75 Q78 80 80 105 Z" />
|
||||
{/* neck */}
|
||||
<path d="M42 78 L58 78 L57 65 L43 65 Z" />
|
||||
{/* head */}
|
||||
<path d="M30 50 Q30 30 50 28 Q70 30 70 50 Q70 62 60 65 L40 65 Q30 62 30 50 Z" />
|
||||
{/* ears */}
|
||||
<path d="M32 38 L26 22 L36 33 Z" />
|
||||
<path d="M68 38 L74 22 L64 33 Z" />
|
||||
{/* muzzle */}
|
||||
<path d="M50 45 Q60 48 60 55 Q55 60 50 60 Q45 60 40 55 Q40 48 50 45 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
felidae: <>
|
||||
<path d="M20 105 Q22 80 35 75 L65 75 Q78 80 80 105 Z" />
|
||||
<path d="M42 78 L58 78 L57 65 L43 65 Z" />
|
||||
<path d="M28 52 Q28 30 50 28 Q72 30 72 52 Q72 62 62 65 L38 65 Q28 62 28 52 Z" />
|
||||
{/* triangular ears */}
|
||||
<path d="M32 36 L28 18 L40 30 Z" />
|
||||
<path d="M68 36 L72 18 L60 30 Z" />
|
||||
<path d="M50 50 Q56 52 56 56 Q53 60 50 60 Q47 60 44 56 Q44 52 50 50 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
mustelidae: <>
|
||||
<path d="M22 105 Q24 82 36 78 L64 78 Q76 82 78 105 Z" />
|
||||
<path d="M44 80 L56 80 L55 68 L45 68 Z" />
|
||||
{/* long narrow head */}
|
||||
<path d="M32 52 Q32 32 50 30 Q68 32 68 52 Q68 64 60 68 L40 68 Q32 64 32 52 Z" />
|
||||
<path d="M34 38 L30 26 L40 34 Z" />
|
||||
<path d="M66 38 L70 26 L60 34 Z" />
|
||||
<path d="M50 48 Q58 51 58 56 Q53 60 50 60 Q47 60 42 56 Q42 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
ursidae: <>
|
||||
<path d="M14 105 Q18 78 32 72 L68 72 Q82 78 86 105 Z" />
|
||||
<path d="M40 76 L60 76 L58 64 L42 64 Z" />
|
||||
{/* round large head */}
|
||||
<path d="M22 50 Q22 26 50 24 Q78 26 78 50 Q78 64 66 68 L34 68 Q22 64 22 50 Z" />
|
||||
{/* small round ears */}
|
||||
<circle cx="28" cy="26" r="6" />
|
||||
<circle cx="72" cy="26" r="6" />
|
||||
<path d="M50 48 Q60 52 60 58 Q54 62 50 62 Q46 62 40 58 Q40 52 50 48 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
cervidae: <>
|
||||
<path d="M22 105 Q24 82 36 76 L64 76 Q76 82 78 105 Z" />
|
||||
<path d="M44 78 L56 78 L55 64 L45 64 Z" />
|
||||
<path d="M32 52 Q32 30 50 28 Q68 30 68 52 Q68 62 60 64 L40 64 Q32 62 32 52 Z" />
|
||||
{/* antlers */}
|
||||
<path d="M40 32 L34 14 M34 14 L28 18 M34 14 L36 8" stroke="var(--ink)" strokeWidth="1.6" fill="none" strokeLinecap="round"/>
|
||||
<path d="M60 32 L66 14 M66 14 L72 18 M66 14 L64 8" stroke="var(--ink)" strokeWidth="1.6" fill="none" strokeLinecap="round"/>
|
||||
<path d="M40 32 L36 26 M60 32 L64 26" stroke="var(--ink)" strokeWidth="1.4" fill="none" strokeLinecap="round" />
|
||||
<path d="M50 48 Q56 51 56 55 Q53 58 50 58 Q47 58 44 55 Q44 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
bovidae: <>
|
||||
<path d="M16 105 Q20 80 34 74 L66 74 Q80 80 84 105 Z" />
|
||||
<path d="M42 78 L58 78 L57 64 L43 64 Z" />
|
||||
<path d="M26 52 Q26 28 50 26 Q74 28 74 52 Q74 64 64 66 L36 66 Q26 64 26 52 Z" />
|
||||
{/* curved horns */}
|
||||
<path d="M28 32 Q12 28 14 18" stroke="var(--ink)" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
<path d="M72 32 Q88 28 86 18" stroke="var(--ink)" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
<path d="M50 48 Q58 51 58 56 Q53 60 50 60 Q47 60 42 56 Q42 51 50 48 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
leporidae: <>
|
||||
<path d="M22 105 Q24 84 36 78 L64 78 Q76 84 78 105 Z" />
|
||||
<path d="M44 80 L56 80 L55 66 L45 66 Z" />
|
||||
<path d="M32 56 Q32 38 50 36 Q68 38 68 56 Q68 64 60 66 L40 66 Q32 64 32 56 Z" />
|
||||
{/* long ears */}
|
||||
<ellipse cx="42" cy="20" rx="3.5" ry="14" />
|
||||
<ellipse cx="58" cy="20" rx="3.5" ry="14" />
|
||||
<ellipse cx="42" cy="22" rx="1.6" ry="9" fill={"rgba(0,0,0,0.25)"} />
|
||||
<ellipse cx="58" cy="22" rx="1.6" ry="9" fill={"rgba(0,0,0,0.25)"} />
|
||||
<path d="M50 52 Q55 54 55 58 Q52 60 50 60 Q48 60 45 58 Q45 54 50 52 Z" fill="rgba(0,0,0,0.25)" />
|
||||
</>,
|
||||
};
|
||||
|
||||
window.Portrait = Portrait;
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Clade & class sigils — simple geometric heraldic glyphs (not figurative). */
|
||||
|
||||
const Sigil = ({ id, size = 30 }) => {
|
||||
const s = size;
|
||||
const stroke = "currentColor";
|
||||
const sw = 1.4;
|
||||
const common = { width: s, height: s, viewBox: "0 0 32 32", fill: "none", stroke, strokeWidth: sw, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||
switch (id) {
|
||||
case "canidae": // pack triangle (three points)
|
||||
return <svg {...common}><path d="M16 5 L27 26 L5 26 Z" /><circle cx="16" cy="18" r="2.2" /><path d="M11 21 L8 26 M21 21 L24 26" /></svg>;
|
||||
case "felidae": // crescent + claw
|
||||
return <svg {...common}><path d="M22 6 a10 10 0 1 0 4 14 a8 8 0 1 1 -4 -14 Z" /><path d="M9 22 L13 18 M13 22 L16 19" /></svg>;
|
||||
case "mustelidae": // sinuous "S"
|
||||
return <svg {...common}><path d="M8 7 q6 0 6 6 t6 6 q6 0 6 6" /><circle cx="6" cy="7" r="1.2" fill={stroke} /><circle cx="26" cy="25" r="1.2" fill={stroke} /></svg>;
|
||||
case "ursidae": // heavy paw
|
||||
return <svg {...common}><circle cx="16" cy="20" r="6" /><circle cx="9" cy="13" r="2.2" /><circle cx="16" cy="9" r="2.2" /><circle cx="23" cy="13" r="2.2" /></svg>;
|
||||
case "cervidae": // antler crown
|
||||
return <svg {...common}><path d="M16 28 V16" /><path d="M16 16 L9 9 M9 9 L6 11 M9 9 L9 5" /><path d="M16 16 L23 9 M23 9 L26 11 M23 9 L23 5" /><path d="M16 16 L13 12 M16 16 L19 12" /></svg>;
|
||||
case "bovidae": // horns
|
||||
return <svg {...common}><path d="M16 22 V14" /><path d="M16 14 q-8 -2 -10 -8 q4 0 7 4 q1 2 3 4" /><path d="M16 14 q8 -2 10 -8 q-4 0 -7 4 q-1 2 -3 4" /><circle cx="16" cy="24" r="3" /></svg>;
|
||||
case "leporidae": // long ears
|
||||
return <svg {...common}><ellipse cx="12" cy="10" rx="2" ry="6" /><ellipse cx="20" cy="10" rx="2" ry="6" /><circle cx="16" cy="22" r="5" /><circle cx="14" cy="21" r="0.6" fill={stroke} /><circle cx="18" cy="21" r="0.6" fill={stroke} /></svg>;
|
||||
default:
|
||||
return <svg {...common}><circle cx="16" cy="16" r="9" /></svg>;
|
||||
}
|
||||
};
|
||||
|
||||
window.Sigil = Sigil;
|
||||
@@ -0,0 +1,647 @@
|
||||
/* The 7 wizard step components.
|
||||
Exports: StepClade, StepSpecies, StepClass, StepBackground, StepStats, StepSkills, StepReview
|
||||
All read/write through the shared `state` object passed via props. */
|
||||
|
||||
const { useState, useMemo, useEffect } = React;
|
||||
|
||||
// ============ STEP 1: CLADE ============
|
||||
const StepClade = ({ state, set, tweaks }) => {
|
||||
const { clades } = state.data;
|
||||
const groupBy = tweaks.predatorPrey;
|
||||
|
||||
const groups = groupBy
|
||||
? [
|
||||
{ label: "Predators", items: clades.filter(c => c.kind === "predator") },
|
||||
{ label: "Prey", items: clades.filter(c => c.kind === "prey") },
|
||||
]
|
||||
: [{ label: null, items: clades }];
|
||||
|
||||
const renderCard = (c) => {
|
||||
const sel = state.cladeId === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={"card scented" + (sel ? " selected" : "")}
|
||||
onClick={() => set({ cladeId: c.id, speciesId: state.data.species.find(s => s.clade_id === c.id)?.id })}
|
||||
>
|
||||
<span className="scent-aura" />
|
||||
<div style={{display: "flex", alignItems: "flex-start", gap: 14}}>
|
||||
<div className="sigil"><Sigil id={c.id} /></div>
|
||||
<div style={{flex: 1}}>
|
||||
<div className="name">{c.name}</div>
|
||||
<div className="meta">{c.kind}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mods">
|
||||
{Object.entries(c.ability_mods).map(([k,v]) => (
|
||||
<span key={k} className={"mod " + (v >= 0 ? "pos" : "neg")}>{k} {signed(v)}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="trait-chips-label">Languages</div>
|
||||
<div className="trait-chips lang-chips">
|
||||
{c.languages.map(l => (
|
||||
<LanguageChip key={l} id={l} />
|
||||
))}
|
||||
</div>
|
||||
<div className="trait-chips-label">Traits</div>
|
||||
<div className="trait-chips">
|
||||
{c.traits.map(t => (
|
||||
<TraitName key={t.id} trait={t} suffix="" />
|
||||
))}
|
||||
{tweaks.showDetriments && c.detriments.map(t => (
|
||||
<TraitName key={t.id} trait={t} detriment suffix="" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio I — Of Bloodlines</div>
|
||||
<h2>Choose your Clade</h2>
|
||||
<p>The seven great families of Theriapolis. Your clade is the body you were born to — the broad shape of your gait, the fall of your shadow, the words your scent carries before you speak.</p>
|
||||
</div>
|
||||
</div>
|
||||
{groups.map(g => (
|
||||
<div key={g.label || "all"}>
|
||||
{g.label && <div className="clade-group-label">{g.label}</div>}
|
||||
<div className="card-grid">{g.items.map(renderCard)}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 2: SPECIES ============
|
||||
const StepSpecies = ({ state, set, tweaks }) => {
|
||||
const clade = state.data.clades.find(c => c.id === state.cladeId);
|
||||
const filtered = state.data.species.filter(s => s.clade_id === state.cladeId);
|
||||
if (!clade) return <p>Pick a clade first.</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio II — Of Lineage within {clade.name}</div>
|
||||
<h2>Choose your Species</h2>
|
||||
<p>Within every clade are kindreds — different statures, ranges, and inheritances. The species refines what the clade began.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-grid">
|
||||
{filtered.map(s => {
|
||||
const sel = state.speciesId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={"card" + (sel ? " selected" : "")}
|
||||
onClick={() => set({ speciesId: s.id })}
|
||||
>
|
||||
<div className="name">{s.name}</div>
|
||||
<div className="meta">{SIZE_LABEL[s.size] || s.size} · {s.base_speed_ft} ft.</div>
|
||||
<div className="mods">
|
||||
{Object.entries(s.ability_mods).map(([k,v]) => (
|
||||
<span key={k} className={"mod " + (v >= 0 ? "pos" : "neg")}>{k} {signed(v)}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="trait-chips" style={{marginTop: 12}}>
|
||||
{s.traits.map(t => (
|
||||
<TraitName key={t.id} trait={t} suffix="" />
|
||||
))}
|
||||
{tweaks.showDetriments && s.detriments.map(t => (
|
||||
<TraitName key={t.id} trait={t} detriment suffix="" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 3: CLASS ============
|
||||
const StepClass = ({ state, set, tweaks }) => {
|
||||
const recommendedClasses = useMemo(() => {
|
||||
const recs = [];
|
||||
Object.entries(CLASS_CLADE_REC).forEach(([cls, clades]) => {
|
||||
if (clades.includes(state.cladeId)) recs.push(cls);
|
||||
});
|
||||
return new Set(recs);
|
||||
}, [state.cladeId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio III — Of Vocations</div>
|
||||
<h2>Choose your Calling</h2>
|
||||
<p>Eight callings exist within the Covenant. Each shapes how you fight, treat, parley, or unmake the world.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-grid">
|
||||
{state.data.classes.map(c => {
|
||||
const sel = state.classId === c.id;
|
||||
const rec = recommendedClasses.has(c.id);
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={"card" + (sel ? " selected" : "")}
|
||||
onClick={() => set({ classId: c.id })}
|
||||
>
|
||||
<div className="name">
|
||||
{c.name}
|
||||
{rec && <span className="badge-rec" title="Suits your clade">★ Suits Clade</span>}
|
||||
</div>
|
||||
<div className="meta">d{c.hit_die} · primary {c.primary_ability.join("/")} · saves {c.saves.join("/")}</div>
|
||||
<div className="trait-chips" style={{marginTop: 12}}>
|
||||
{(c.level_table?.find(l => l.level === 1)?.features || [])
|
||||
.filter(k => !["asi","subclass_select","subclass_feature"].includes(k))
|
||||
.map(k => {
|
||||
const f = c.feature_definitions[k];
|
||||
if (!f) return null;
|
||||
return <TraitName key={k} trait={{ id: c.id+"_"+k, name: f.name, description: f.description, tag: f.kind }} suffix="" />;
|
||||
})}
|
||||
</div>
|
||||
<div style={{marginTop: 10, fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.16em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
|
||||
Picks {c.skills_choose} skill{c.skills_choose > 1 ? "s" : ""} · armor: {c.armor_proficiencies.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 4: BACKGROUND ============
|
||||
const StepBackground = ({ state, set, tweaks }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio IV — Of Histories</div>
|
||||
<h2>Choose your Background</h2>
|
||||
<p>Where the clade gives you body and the calling gives you craft, the background gives you a past — debts, contacts, scars, the way you sleep.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-grid">
|
||||
{state.data.backgrounds.map(b => {
|
||||
const sel = state.backgroundId === b.id;
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={"card bg-card" + (sel ? " selected" : "")}
|
||||
onClick={() => set({ backgroundId: b.id })}
|
||||
>
|
||||
<div className="name">{b.name}</div>
|
||||
{tweaks.showFlavor && <div className="flavor">{b.flavor}</div>}
|
||||
<div className="trait-chips-label" style={{marginTop: 10}}>Feature</div>
|
||||
<div className="trait-chips bg-feat-row">
|
||||
<TraitName trait={{ id: b.id+"_feat", name: b.feature_name, description: b.feature_description, tag: "feature" }} suffix="" />
|
||||
</div>
|
||||
<div className="trait-chips-label" style={{marginTop: 10}}>Skills</div>
|
||||
<div className="trait-chips">
|
||||
{b.skill_proficiencies.map(s => <SkillChip key={s} id={s} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 5: STATS ============
|
||||
const StepStats = ({ state, set, tweaks }) => {
|
||||
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 method = state.statMethod; // "array" | "roll"
|
||||
|
||||
// Drag/drop handlers cover three cases:
|
||||
// pool -> slot (assign; if slot has a value, return that to pool)
|
||||
// slot -> slot (move; if dest has a value, swap the two)
|
||||
// slot -> pool (return value to pool)
|
||||
const handleDrop = (destAbility, payload) => {
|
||||
const newPool = [...state.statPool];
|
||||
const newAssign = { ...state.statAssign };
|
||||
if (payload.from === "pool") {
|
||||
// If dest already has a value, return it to the pool first.
|
||||
if (newAssign[destAbility] != null) {
|
||||
newPool.push({ value: newAssign[destAbility] });
|
||||
}
|
||||
newPool.splice(payload.idx, 1);
|
||||
newAssign[destAbility] = payload.value;
|
||||
} else if (payload.from === "slot") {
|
||||
const srcAbility = payload.ability;
|
||||
if (srcAbility === destAbility) return;
|
||||
const srcVal = newAssign[srcAbility];
|
||||
const destVal = newAssign[destAbility];
|
||||
// swap (or move if dest empty)
|
||||
newAssign[destAbility] = srcVal;
|
||||
if (destVal != null) newAssign[srcAbility] = destVal;
|
||||
else delete newAssign[srcAbility];
|
||||
}
|
||||
set({ statPool: newPool, statAssign: newAssign });
|
||||
};
|
||||
|
||||
// Drop a slot value back into the pool.
|
||||
const dropToPool = (payload) => {
|
||||
if (payload.from !== "slot") return;
|
||||
const newAssign = { ...state.statAssign };
|
||||
const v = newAssign[payload.ability];
|
||||
if (v == null) return;
|
||||
delete newAssign[payload.ability];
|
||||
set({
|
||||
statPool: [...state.statPool, { value: v }],
|
||||
statAssign: newAssign,
|
||||
});
|
||||
};
|
||||
|
||||
const clearAbility = (ab) => {
|
||||
const newPool = [...state.statPool];
|
||||
const newAssign = { ...state.statAssign };
|
||||
if (newAssign[ab] != null) {
|
||||
newPool.push({ value: newAssign[ab], used: false });
|
||||
delete newAssign[ab];
|
||||
}
|
||||
set({ statPool: newPool, statAssign: newAssign });
|
||||
};
|
||||
|
||||
const reroll = () => {
|
||||
const r = () => {
|
||||
const dice = Array.from({length: 4}, () => 1 + Math.floor(Math.random() * 6));
|
||||
dice.sort((a,b) => a-b);
|
||||
return dice[1] + dice[2] + dice[3];
|
||||
};
|
||||
const vals = Array.from({length: 6}, r);
|
||||
set({
|
||||
statPool: vals.map(v => ({ value: v })),
|
||||
statAssign: {},
|
||||
statHistory: [...(state.statHistory || []), { vals, ts: Date.now() }],
|
||||
});
|
||||
};
|
||||
|
||||
const useArray = () => {
|
||||
set({
|
||||
statMethod: "array",
|
||||
statPool: STANDARD_ARRAY.map(v => ({ value: v })),
|
||||
statAssign: {},
|
||||
});
|
||||
};
|
||||
const useRoll = () => {
|
||||
set({ statMethod: "roll" });
|
||||
reroll();
|
||||
};
|
||||
|
||||
const autoAssign = () => {
|
||||
if (!cls) return;
|
||||
// Honor abilities the user already pinned. Only fill remaining ones from
|
||||
// values still in the pool, and only into ability slots still empty.
|
||||
const newAssign = { ...state.statAssign };
|
||||
const assignedAbilities = new Set(Object.keys(newAssign));
|
||||
|
||||
// Order of preference: class primary first (any not yet pinned), then
|
||||
// a sensible default fallback for the rest.
|
||||
const order = [...cls.primary_ability];
|
||||
["CON","DEX","STR","WIS","INT","CHA"].forEach(a => { if (!order.includes(a)) order.push(a); });
|
||||
const remainingAbilities = order.filter(a => !assignedAbilities.has(a));
|
||||
|
||||
// Sort the still-in-pool values descending and place onto remaining
|
||||
// abilities in preference order.
|
||||
const remainingValues = [...state.statPool.map(p => p.value)].sort((a,b) => b-a);
|
||||
|
||||
const newPool = [];
|
||||
remainingAbilities.forEach((a, i) => {
|
||||
if (i < remainingValues.length) {
|
||||
newAssign[a] = remainingValues[i];
|
||||
}
|
||||
});
|
||||
// If pool had more values than empty slots (shouldn't happen, but be
|
||||
// defensive), keep the leftovers in the pool.
|
||||
if (remainingValues.length > remainingAbilities.length) {
|
||||
remainingValues.slice(remainingAbilities.length).forEach(v => {
|
||||
newPool.push({ value: v });
|
||||
});
|
||||
}
|
||||
|
||||
set({ statPool: newPool, statAssign: newAssign });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio V — Of Aptitudes</div>
|
||||
<h2>Set your Abilities</h2>
|
||||
<p>Six numbers describe what your body and mind can do. Drag values from the pool onto the abilities — your primary calling preference is suggested.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-tabs">
|
||||
<div className={"stat-tab" + (method === "array" ? " active" : "")} onClick={useArray}>Standard Array</div>
|
||||
<div className={"stat-tab" + (method === "roll" ? " active" : "")} onClick={useRoll}>Roll 4d6 — drop lowest</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pool"
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("drag-over"); }}
|
||||
onDragLeave={(e) => e.currentTarget.classList.remove("drag-over")}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
try {
|
||||
const payload = JSON.parse(e.dataTransfer.getData("text/plain"));
|
||||
dropToPool(payload);
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
{state.statPool.length === 0 && <div style={{fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>All values assigned. Drag from a slot to return.</div>}
|
||||
{state.statPool.map((p, i) => (
|
||||
<div
|
||||
key={i+"-"+p.value}
|
||||
className="die"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ from: "pool", value: p.value, idx: i }));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
>{p.value}</div>
|
||||
))}
|
||||
<div style={{marginLeft: "auto", display: "flex", gap: 8, alignItems: "center"}}>
|
||||
{method === "roll" && <button className="btn small ghost" onClick={reroll}>Reroll</button>}
|
||||
<button className="btn small ghost" onClick={autoAssign} disabled={state.statPool.length === 0}>Auto-assign</button>
|
||||
<button className="btn small ghost" onClick={() => { const ks = Object.keys(state.statAssign); const vals = ks.map(k => state.statAssign[k]); set({ statPool: [...state.statPool, ...vals.map(v => ({value: v}))], statAssign: {}}); }}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roll history */}
|
||||
{method === "roll" && (state.statHistory || []).length > 1 && (
|
||||
<div style={{marginBottom: 18, fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-mute)"}}>
|
||||
<strong style={{fontFamily: "var(--serif-display)", fontStyle: "italic", fontSize: 13, color: "var(--ink-soft)", textTransform: "none", letterSpacing: 0}}>Previous rolls:</strong>{" "}
|
||||
{state.statHistory.slice(0, -1).slice(-3).map((h, i) => (
|
||||
<span key={i} style={{marginRight: 12}}>[{h.vals.join(", ")}]</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{ABILITIES.map(ab => {
|
||||
const v = state.statAssign[ab];
|
||||
const cladeMod = clade?.ability_mods[ab] || 0;
|
||||
const speciesMod = species?.ability_mods[ab] || 0;
|
||||
const totalBonus = cladeMod + speciesMod;
|
||||
const bonusSources = [];
|
||||
if (cladeMod) bonusSources.push({ source: clade?.name || "Clade", value: cladeMod });
|
||||
if (speciesMod) bonusSources.push({ source: species?.name || "Species", value: speciesMod });
|
||||
const final = (v ?? 0) + cladeMod + speciesMod;
|
||||
const finalMod = abilityMod(final);
|
||||
const isPrimary = cls?.primary_ability.includes(ab);
|
||||
return (
|
||||
<div className="ability-row" key={ab}>
|
||||
<div className="ab-name">
|
||||
<span style={{display: "inline-flex", alignItems: "center", gap: 6}}>
|
||||
{ab}
|
||||
{totalBonus !== 0 && (
|
||||
<BonusPill total={totalBonus} sources={bonusSources} ability={ab} />
|
||||
)}
|
||||
</span>
|
||||
<small>{ABILITY_LABELS[ab]}{isPrimary && " · primary"}</small>
|
||||
</div>
|
||||
<div
|
||||
className={"slot" + (v != null ? " filled" : "")}
|
||||
draggable={v != null}
|
||||
onDragStart={(e) => {
|
||||
if (v == null) { e.preventDefault(); return; }
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ from: "slot", value: v, ability: ab }));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("drag-over"); }}
|
||||
onDragLeave={(e) => e.currentTarget.classList.remove("drag-over")}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
try {
|
||||
const payload = JSON.parse(e.dataTransfer.getData("text/plain"));
|
||||
handleDrop(ab, payload);
|
||||
} catch {}
|
||||
}}
|
||||
onClick={() => v != null && clearAbility(ab)}
|
||||
title={v != null ? "Drag to swap, or click to return to pool" : "Drop a value here"}
|
||||
>
|
||||
{v ?? "—"}
|
||||
</div>
|
||||
<div className="formula">
|
||||
{v != null && (cladeMod || speciesMod) ? `base ${v}` : (v != null ? "" : "")}
|
||||
</div>
|
||||
<div style={{display: "flex", flexDirection: "column"}}>
|
||||
<div className="ab-name" style={{fontSize: 22}}>
|
||||
{v != null ? final : "—"}
|
||||
<small style={{color: finalMod >= 0 ? "var(--seal)" : "var(--ink-mute)", letterSpacing: "0.1em"}}>{v != null ? signed(finalMod) : ""}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bar">
|
||||
<div className="bar-fill" style={{width: Math.max(0, Math.min(100, (final/20)*100)) + "%"}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 6: SKILLS ============
|
||||
const StepSkills = ({ state, set, tweaks }) => {
|
||||
const cls = state.data.classes.find(c => c.id === state.classId);
|
||||
const bg = state.data.backgrounds.find(b => b.id === state.backgroundId);
|
||||
if (!cls) return <p>Pick a class first.</p>;
|
||||
|
||||
const lockedFromBg = new Set((bg?.skill_proficiencies || []));
|
||||
const classOptions = new Set(cls.skill_options);
|
||||
const required = cls.skills_choose;
|
||||
const chosen = state.chosenSkills;
|
||||
|
||||
const toggle = (skillId) => {
|
||||
if (lockedFromBg.has(skillId)) return;
|
||||
const has = chosen.includes(skillId);
|
||||
if (has) set({ chosenSkills: chosen.filter(s => s !== skillId) });
|
||||
else if (chosen.length < required) set({ chosenSkills: [...chosen, skillId] });
|
||||
};
|
||||
|
||||
// Group all 18 skills by ability
|
||||
const grouped = {};
|
||||
ABILITIES.forEach(a => grouped[a] = []);
|
||||
Object.entries(SKILL_LABEL).forEach(([id]) => {
|
||||
grouped[SKILL_ABILITY[id]].push(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio VI — Of Trained Hands</div>
|
||||
<h2>Choose your Skills</h2>
|
||||
<p>Your background grants two skills automatically (sealed). From your calling's offered list, choose {required} more.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skills-meta">
|
||||
<div>
|
||||
<div className="count"><em>{chosen.length}</em> / {required} chosen</div>
|
||||
<div style={{fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.2em", color: "var(--ink-mute)", textTransform: "uppercase", marginTop: 2}}>
|
||||
+ {lockedFromBg.size} sealed by background
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
|
||||
Class: {cls.name} · Background: {bg?.name || "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-grid-by-ability">
|
||||
{ABILITIES.map(ab => (
|
||||
<div className="skill-group" key={ab}>
|
||||
<h5>
|
||||
{ABILITY_LABELS[ab]}
|
||||
<small>{ab}</small>
|
||||
</h5>
|
||||
{grouped[ab].map(skillId => {
|
||||
const fromBg = lockedFromBg.has(skillId);
|
||||
const fromClass = classOptions.has(skillId);
|
||||
const checked = chosen.includes(skillId);
|
||||
const klass = fromBg ? "locked" : (!fromClass ? "unavailable" : (checked ? "checked" : ""));
|
||||
return (
|
||||
<div
|
||||
key={skillId}
|
||||
className={"skill-row " + klass}
|
||||
onClick={() => fromClass && toggle(skillId)}
|
||||
>
|
||||
<div className="label">
|
||||
<div className="check">{(checked || fromBg) ? "✓" : ""}</div>
|
||||
<SkillChip id={skillId} />
|
||||
</div>
|
||||
<div className="source-tag">
|
||||
{fromBg ? "Background" : (fromClass ? "Class" : "—")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ STEP 7: NAME + REVIEW ============
|
||||
const StepReview = ({ state, set, tweaks, goTo }) => {
|
||||
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 className="page-intro">
|
||||
<div>
|
||||
<div className="eyebrow">Folio VII — Of Names & Witness</div>
|
||||
<h2>Sign the Codex</h2>
|
||||
<p>Review your character. The name you sign here is the one the world will speak.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginBottom: 22}}>
|
||||
<h4 style={{marginBottom: 4}}>Name</h4>
|
||||
<input
|
||||
type="text"
|
||||
value={state.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
placeholder="Wanderer"
|
||||
style={{maxWidth: 480}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="review-grid">
|
||||
<div className="review-block">
|
||||
<div className="head">
|
||||
<h3 style={{fontStyle: "italic"}}>{clade?.name}</h3>
|
||||
<button className="edit-link" onClick={() => goTo(0)}>Edit ›</button>
|
||||
</div>
|
||||
<div style={{fontFamily: "var(--serif-display)", fontSize: 18}}>{species?.name} <span style={{color: "var(--ink-mute)", fontSize: 13, fontFamily: "var(--mono)", letterSpacing: "0.16em", textTransform: "uppercase", marginLeft: 8}}>{SIZE_LABEL[species?.size]}</span></div>
|
||||
</div>
|
||||
<div className="review-block">
|
||||
<div className="head">
|
||||
<h3 style={{fontStyle: "italic"}}>{cls?.name}</h3>
|
||||
<button className="edit-link" onClick={() => goTo(2)}>Edit ›</button>
|
||||
</div>
|
||||
<div style={{fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.18em", color: "var(--ink-mute)", textTransform: "uppercase"}}>
|
||||
d{cls?.hit_die} · {cls?.primary_ability.join("/")}
|
||||
</div>
|
||||
<div style={{fontFamily: "var(--serif-display)", fontStyle: "italic", color: "var(--ink-soft)", marginTop: 6, fontSize: 14}}>
|
||||
{bg?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-block" style={{marginTop: 18}}>
|
||||
<div className="head">
|
||||
<h3 style={{fontStyle: "italic"}}>Final Abilities</h3>
|
||||
<button className="edit-link" onClick={() => goTo(4)}>Edit ›</button>
|
||||
</div>
|
||||
<div className="stat-strip" style={{marginTop: 6}}>
|
||||
{ABILITIES.map(ab => {
|
||||
const base = state.statAssign[ab] || 0;
|
||||
const cm = clade?.ability_mods[ab] || 0;
|
||||
const sm = species?.ability_mods[ab] || 0;
|
||||
const f = base + cm + sm;
|
||||
const m = abilityMod(f);
|
||||
return (
|
||||
<div className="cell" key={ab}>
|
||||
<div className="ab">{ab}</div>
|
||||
<div className="sc">{f}</div>
|
||||
<div className={"md" + (m < 0 ? " neg" : "")}>{signed(m)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-block" style={{marginTop: 18}}>
|
||||
<div className="head">
|
||||
<h3 style={{fontStyle: "italic"}}>Skills</h3>
|
||||
<button className="edit-link" onClick={() => goTo(5)}>Edit ›</button>
|
||||
</div>
|
||||
<div className="mods" style={{marginTop: 4}}>
|
||||
{(bg?.skill_proficiencies || []).map(s => (
|
||||
<span key={s} className="mod" style={{borderColor: "var(--gild)", color: "var(--gild)"}}>{SKILL_LABEL[s]} · BG</span>
|
||||
))}
|
||||
{state.chosenSkills.map(s => (
|
||||
<span key={s} className="mod pos">{SKILL_LABEL[s]}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="review-block" style={{marginTop: 18}}>
|
||||
<div className="head">
|
||||
<h3 style={{fontStyle: "italic"}}>Starting Kit</h3>
|
||||
<button className="edit-link" onClick={() => goTo(2)}>Edit ›</button>
|
||||
</div>
|
||||
<div className="kit-grid" style={{marginTop: 6}}>
|
||||
{(cls?.starting_kit || []).map((it, i) => (
|
||||
<div className={"kit-item" + (it.auto_equip ? " equipped" : "")} key={i}>
|
||||
<div>{ITEM_NAME[it.item_id] || it.item_id}</div>
|
||||
<div className="qty">×{it.qty}</div>
|
||||
{it.auto_equip && <div className="equipped-tag">{it.equip_slot}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
window.Steps = { StepClade, StepSpecies, StepClass, StepBackground, StepStats, StepSkills, StepReview };
|
||||
@@ -0,0 +1,180 @@
|
||||
/* TraitName — clickable/hoverable trait name that shows a hint window with description.
|
||||
Stays open as long as the mouse is over the trait name OR the hint window itself. */
|
||||
|
||||
const TraitName = ({ trait, detriment = false, suffix = ".", className = "", label = null }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [pos, setPos] = React.useState({ left: 0, top: 0, arrowLeft: 18 });
|
||||
const triggerRef = React.useRef(null);
|
||||
const hintRef = React.useRef(null);
|
||||
const closeTimerRef = React.useRef(null);
|
||||
|
||||
// We allow a tiny grace period so moving the cursor from the trigger to the
|
||||
// hint window doesn't cause a flicker-close. Both trigger and hint cancel
|
||||
// the timer on enter and start it on leave.
|
||||
const cancelClose = () => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimerRef.current = setTimeout(() => setOpen(false), 80);
|
||||
};
|
||||
|
||||
// Re-measure & clamp. Wrapped so we can call from the layout effect AND
|
||||
// from scroll/resize. Stored in a ref to avoid re-creating the listener.
|
||||
const measure = React.useCallback(() => {
|
||||
if (!hintRef.current || !triggerRef.current) return;
|
||||
const PAD = 8;
|
||||
const trig = triggerRef.current.getBoundingClientRect();
|
||||
const hint = hintRef.current.getBoundingClientRect();
|
||||
// documentElement.clientWidth excludes the vertical scrollbar gutter,
|
||||
// which window.innerWidth sometimes includes. Using clientWidth keeps
|
||||
// the popover from poking into the scrollbar lane.
|
||||
const vw = document.documentElement.clientWidth || window.innerWidth;
|
||||
const vh = document.documentElement.clientHeight || window.innerHeight;
|
||||
|
||||
// Default: under trigger, left-aligned with trigger.
|
||||
let left = trig.left;
|
||||
let top = trig.bottom + 6;
|
||||
let placement = "below";
|
||||
|
||||
// Flip above if there's no room below and there's room above.
|
||||
if (top + hint.height + PAD > vh && trig.top - 6 - hint.height >= PAD) {
|
||||
top = trig.top - 6 - hint.height;
|
||||
placement = "above";
|
||||
}
|
||||
|
||||
// Clamp horizontally inside the viewport
|
||||
if (left + hint.width + PAD > vw) left = vw - hint.width - PAD;
|
||||
if (left < PAD) left = PAD;
|
||||
|
||||
// Clamp vertically (in case neither above nor below quite fits — better
|
||||
// to overlap the trigger than escape the viewport).
|
||||
if (top + hint.height + PAD > vh) top = vh - hint.height - PAD;
|
||||
if (top < PAD) top = PAD;
|
||||
|
||||
// Arrow follows trigger center, clamped within the popover's edges.
|
||||
const triggerCenter = trig.left + trig.width / 2;
|
||||
const arrowLeft = Math.max(12, Math.min(hint.width - 24, triggerCenter - left - 6));
|
||||
|
||||
setPos({
|
||||
left: left + window.scrollX,
|
||||
top: top + window.scrollY,
|
||||
arrowLeft,
|
||||
placement,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// After the hint mounts, measure & clamp.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
measure();
|
||||
}, [open, measure]);
|
||||
|
||||
React.useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// Recalculate on scroll/resize while open.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener("scroll", measure, true);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", measure, true);
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [open, measure]);
|
||||
|
||||
const onEnter = () => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const reading = TRAIT_READING[trait.id];
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={"t-name trait-trigger " + className + (detriment ? " detriment" : "")}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={scheduleClose}
|
||||
>{label != null ? label : (trait.name + suffix)}</span>
|
||||
{open && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={hintRef}
|
||||
className={"trait-hint" + (detriment ? " detriment" : "") + (pos.placement === "above" ? " above" : "")}
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
"--arrow-left": pos.arrowLeft + "px",
|
||||
}}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<div className="trait-hint-name">
|
||||
{trait.name}
|
||||
{trait.tag && <span className="trait-hint-tag">{trait.tag}</span>}
|
||||
{detriment && <span className="trait-hint-tag">detriment</span>}
|
||||
</div>
|
||||
<div className="trait-hint-desc">{trait.description}</div>
|
||||
{reading && (
|
||||
<div className="trait-hint-reading">{reading}</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
window.TraitName = TraitName;
|
||||
|
||||
/* LanguageChip — same hover-popover pattern but for a language id. */
|
||||
const LanguageChip = ({ id }) => {
|
||||
const lang = LANGUAGES[id] || { name: id, description: "Unknown tongue." };
|
||||
// Re-use TraitName by giving it a trait-shaped object.
|
||||
const trait = { id: "lang_" + id, name: lang.name, description: lang.description };
|
||||
return <TraitName trait={trait} suffix="" />;
|
||||
};
|
||||
|
||||
window.LanguageChip = LanguageChip;
|
||||
|
||||
/* SkillChip — hover-popover for a skill id. Shows label, governing ability,
|
||||
and the codex-flavored description. */
|
||||
const SkillChip = ({ id, suffix = "", className = "", labelOverride = null }) => {
|
||||
const name = SKILL_LABEL[id] || id;
|
||||
const desc = SKILL_DESC[id] || "A skill of Theriapolis.";
|
||||
const ab = SKILL_ABILITY[id];
|
||||
const trait = {
|
||||
id: "skill_" + id,
|
||||
name,
|
||||
description: desc,
|
||||
tag: ab,
|
||||
};
|
||||
return <TraitName trait={trait} suffix={suffix} className={className} label={labelOverride} />;
|
||||
};
|
||||
|
||||
window.SkillChip = SkillChip;
|
||||
|
||||
/* BonusPill — small badge showing a numeric bonus, with a hover popover
|
||||
that lists the sources of the bonus (clade, species, etc.) */
|
||||
const BonusPill = ({ total, sources, ability }) => {
|
||||
if (!sources || sources.length === 0) return null;
|
||||
const breakdown = sources.map(s => `${signed(s.value)} from ${s.source}`).join(" · ");
|
||||
const trait = {
|
||||
id: "bonus_" + ability,
|
||||
name: ability + " modifier",
|
||||
description: breakdown,
|
||||
};
|
||||
return (
|
||||
<TraitName
|
||||
trait={trait}
|
||||
label={signed(total)}
|
||||
className={"bonus-pill " + (total >= 0 ? "pos" : "neg")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
window.BonusPill = BonusPill;
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Tweaks panel wiring. */
|
||||
const { useEffect: useTE } = React;
|
||||
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"theme": "dark",
|
||||
"density": "comfortable",
|
||||
"fontPair": "garamond",
|
||||
"showFlavor": true,
|
||||
"showDetriments": true,
|
||||
"predatorPrey": false,
|
||||
"plainReadings": false
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
const FONT_PAIRS = {
|
||||
garamond: { display: "'Cormorant Garamond', serif", body: "'Crimson Pro', serif" },
|
||||
spectral: { display: "'Spectral', serif", body: "'Spectral', serif" },
|
||||
cinzel: { display: "'Cinzel', serif", body: "'EB Garamond', serif" },
|
||||
uncial: { display: "'Uncial Antiqua', serif", body: "'EB Garamond', serif" },
|
||||
};
|
||||
|
||||
const TweaksWiring = ({ tweaks, setTweaks }) => {
|
||||
// Apply theme + density + fonts to root
|
||||
useTE(() => {
|
||||
document.documentElement.dataset.theme = tweaks.theme;
|
||||
document.documentElement.dataset.density = tweaks.density;
|
||||
const pair = FONT_PAIRS[tweaks.fontPair] || FONT_PAIRS.garamond;
|
||||
document.documentElement.style.setProperty("--serif-display", pair.display);
|
||||
document.documentElement.style.setProperty("--serif-body", pair.body);
|
||||
}, [tweaks.theme, tweaks.density, tweaks.fontPair]);
|
||||
|
||||
return (
|
||||
<TweaksPanel title="Tweaks">
|
||||
<TweakSection label="Appearance">
|
||||
<TweakRadio label="Theme" value={tweaks.theme} onChange={(v) => setTweaks({ theme: v })}
|
||||
options={[{value:"parchment", label:"Parchment"}, {value:"dark", label:"Candlelit"}, {value:"blood", label:"Blood-warm"}]} />
|
||||
<TweakRadio label="Density" value={tweaks.density} onChange={(v) => setTweaks({ density: v })}
|
||||
options={[{value:"comfortable", label:"Comfortable"}, {value:"compact", label:"Compact"}]} />
|
||||
<TweakSelect label="Font pairing" value={tweaks.fontPair} onChange={(v) => setTweaks({ fontPair: v })}
|
||||
options={[
|
||||
{value:"garamond", label:"Cormorant + Crimson"},
|
||||
{value:"spectral", label:"Spectral throughout"},
|
||||
{value:"cinzel", label:"Cinzel + Garamond"},
|
||||
{value:"uncial", label:"Uncial + Garamond"},
|
||||
]} />
|
||||
</TweakSection>
|
||||
<TweakSection label="Content">
|
||||
<TweakToggle label="Show flavor text on backgrounds" value={tweaks.showFlavor} onChange={(v) => setTweaks({ showFlavor: v })} />
|
||||
<TweakToggle label="Show detriments alongside traits" value={tweaks.showDetriments} onChange={(v) => setTweaks({ showDetriments: v })} />
|
||||
<TweakToggle label="Group clades by predator/prey" value={tweaks.predatorPrey} onChange={(v) => setTweaks({ predatorPrey: v })} />
|
||||
<TweakToggle label="Plain-language readings on hover" value={tweaks.plainReadings} onChange={(v) => setTweaks({ plainReadings: v })} />
|
||||
</TweakSection>
|
||||
</TweaksPanel>
|
||||
);
|
||||
};
|
||||
|
||||
window.TweakDefaults = TWEAK_DEFAULTS;
|
||||
window.TweaksWiring = TweaksWiring;
|
||||
Reference in New Issue
Block a user