From a6218c72d41ba464fb4ce0cdf9bd9c6c6d327b1f Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 11:54:53 -0400 Subject: [PATCH] feat: add CharacterWizard multi-step character creation modal Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CharacterWizard.module.css | 369 ++++++++++++++++++ client/src/components/CharacterWizard.tsx | 262 +++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 client/src/components/CharacterWizard.module.css create mode 100644 client/src/components/CharacterWizard.tsx diff --git a/client/src/components/CharacterWizard.module.css b/client/src/components/CharacterWizard.module.css new file mode 100644 index 0000000..9d63366 --- /dev/null +++ b/client/src/components/CharacterWizard.module.css @@ -0,0 +1,369 @@ +/* ── Backdrop ─────────────────────────────────────────── */ +.backdrop { + position: fixed; + inset: 0; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +/* ── Dialog ───────────────────────────────────────────── */ +.dialog { + background-color: var(--bg-modal); + background-image: var(--texture-surface), var(--texture-speckle); + background-size: 256px 256px, 128px 128px; + background-repeat: repeat, repeat; + border: 2px solid rgba(var(--gold-rgb), 0.3); + border-radius: 4px; + padding: 1.5rem; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: + 0 8px 40px rgba(var(--shadow-rgb), 0.7), + 0 2px 8px rgba(var(--shadow-rgb), 0.5), + inset 0 1px 0 rgba(var(--gold-rgb), 0.1), + inset 0 0 60px rgba(var(--shadow-rgb), 0.2); + scrollbar-width: thin; + scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent; +} + +/* ── Header ───────────────────────────────────────────── */ +.title { + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 700; + color: var(--gold); + letter-spacing: 0.05em; + text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.3); + margin-bottom: 0.25rem; +} + +.stepLabel { + font-size: 0.75rem; + color: var(--text-tertiary); + font-family: var(--font-display); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 1rem; +} + +/* ── Progress dots ────────────────────────────────────── */ +.dots { + display: flex; + gap: 0.4rem; + margin-bottom: 1.25rem; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(var(--gold-rgb), 0.15); + border: 1px solid rgba(var(--gold-rgb), 0.25); + transition: background 0.2s; +} + +.dotActive { + background: var(--gold); + border-color: var(--gold); +} + +.dotDone { + background: rgba(var(--gold-rgb), 0.45); + border-color: rgba(var(--gold-rgb), 0.45); +} + +/* ── Form fields ──────────────────────────────────────── */ +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.75rem; +} + +.fieldLabel { + font-family: var(--font-display); + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.05em; +} + +.input { + padding: 0.5rem 0.75rem; + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.15); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.9rem; + font-family: "Alegreya", Georgia, serif; +} + +.input:focus { + outline: none; + border-color: var(--gold); +} + +/* ── Stat rolling (step 2) ────────────────────────────── */ +.statGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.statRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--bg-inset); + border: 1px solid rgba(var(--gold-rgb), 0.12); + border-radius: 4px; +} + +.statName { + font-family: var(--font-display); + font-size: 0.7rem; + color: var(--text-secondary); + letter-spacing: 0.05em; + font-weight: 600; +} + +.statValue { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + min-width: 2ch; + text-align: right; +} + +.statMod { + font-size: 0.75rem; + color: var(--text-tertiary); + min-width: 3ch; + text-align: right; +} + +.rerollBtn { + width: 100%; + padding: 0.5rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.25); + border-radius: 4px; + color: var(--text-secondary); + font-family: var(--font-display); + font-size: 0.8rem; + cursor: pointer; + letter-spacing: 0.05em; + transition: border-color 0.15s, color 0.15s; + margin-bottom: 0.75rem; +} + +.rerollBtn:hover { + border-color: rgba(var(--gold-rgb), 0.5); + color: var(--gold); +} + +/* ── Background list (step 3) ─────────────────────────── */ +.bgList { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: 180px; + overflow-y: auto; + margin-bottom: 0.5rem; + scrollbar-width: thin; + scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent; + border: 1px solid rgba(var(--gold-rgb), 0.1); + border-radius: 4px; + padding: 0.35rem; + background: var(--bg-inset); +} + +.bgItem { + padding: 0.4rem 0.6rem; + border-radius: 3px; + border: 1px solid transparent; + cursor: pointer; + font-size: 0.88rem; + font-family: "Alegreya", Georgia, serif; + transition: background 0.1s, border-color 0.1s; + color: var(--text-primary); +} + +.bgItem:hover { + background: rgba(var(--gold-rgb), 0.06); +} + +.bgItemSelected { + background: rgba(var(--gold-rgb), 0.12); + border-color: rgba(var(--gold-rgb), 0.3); + color: var(--gold); +} + +.bgSkill { + font-size: 0.75rem; + color: var(--text-tertiary); + font-style: italic; + margin-top: 0.2rem; + line-height: 1.4; +} + +.randomBtn { + width: 100%; + padding: 0.35rem; + background: none; + border: 1px dashed rgba(var(--gold-rgb), 0.2); + border-radius: 4px; + color: var(--text-tertiary); + font-size: 0.8rem; + cursor: pointer; + font-family: var(--font-display); + letter-spacing: 0.05em; + transition: border-color 0.15s, color 0.15s; + margin-bottom: 0.75rem; +} + +.randomBtn:hover { + border-color: rgba(var(--gold-rgb), 0.4); + color: var(--text-secondary); +} + +/* ── Review (step 4) ──────────────────────────────────── */ +.reviewSectionTitle { + font-family: var(--font-display); + font-size: 0.75rem; + color: var(--gold); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.4rem; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.15); + padding-bottom: 0.2rem; + margin-top: 0.5rem; +} + +.reviewGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem 1rem; + margin-bottom: 0.5rem; +} + +.reviewRow { + display: flex; + justify-content: space-between; + align-items: baseline; + border-bottom: 1px solid rgba(var(--gold-rgb), 0.07); + padding-bottom: 0.2rem; +} + +.reviewLabel { + font-family: var(--font-display); + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.reviewValue { + font-size: 0.88rem; + color: var(--text-primary); + font-family: "Alegreya", Georgia, serif; + text-align: right; +} + +.reviewStats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.reviewStat { + text-align: center; + padding: 0.35rem; + background: var(--bg-inset); + border-radius: 4px; + border: 1px solid rgba(var(--gold-rgb), 0.1); +} + +.reviewStatName { + font-family: var(--font-display); + font-size: 0.65rem; + color: var(--text-tertiary); + letter-spacing: 0.05em; +} + +.reviewStatVal { + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); +} + +.reviewStatMod { + font-size: 0.7rem; + color: var(--text-tertiary); +} + +/* ── Navigation buttons ───────────────────────────────── */ +.actions { + display: flex; + gap: 0.5rem; + justify-content: space-between; + margin-top: 1rem; +} + +.btnSecondary { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + font-size: 0.9rem; + background: none; + border: 1px solid rgba(var(--gold-rgb), 0.2); + color: var(--text-secondary); + font-family: var(--font-display); + transition: border-color 0.15s, color 0.15s; +} + +.btnSecondary:hover { + border-color: rgba(var(--gold-rgb), 0.4); + color: var(--text-primary); +} + +.btnPrimary { + padding: 0.5rem 1.25rem; + background: var(--btn-gold-bg); + color: var(--btn-active-text); + border: none; + border-radius: 4px; + font-family: var(--font-display); + font-weight: 600; + cursor: pointer; + font-size: 0.9rem; + box-shadow: + 0 2px 4px rgba(var(--shadow-rgb), 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + text-shadow: 0 1px 1px rgba(var(--shadow-rgb), 0.2); +} + +.btnPrimary:hover { + background: linear-gradient( + 180deg, + var(--gold-bright), + var(--gold-hover) 40%, + var(--gold) + ); +} + +.btnPrimary:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/client/src/components/CharacterWizard.tsx b/client/src/components/CharacterWizard.tsx new file mode 100644 index 0000000..77efde5 --- /dev/null +++ b/client/src/components/CharacterWizard.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import type { CreateCharacterData } from "../api.js"; +import SelectDropdown from "./SelectDropdown.js"; +import { BACKGROUNDS, type Background } from "../data/backgrounds.js"; +import { + rollAllStats, + rollStartingHp, + rollStartingGold, + startingGearSlots, + getStartingTitle, +} from "../utils/character-creation.js"; +import { getModifier, formatModifier } from "../utils/modifiers.js"; +import styles from "./CharacterWizard.module.css"; + +const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; +const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; +const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"]; +const STAT_NAMES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; + +const STEP_LABELS = [ + "Step 1 of 4 — Name & Origins", + "Step 2 of 4 — Ability Scores", + "Step 3 of 4 — Background & Beliefs", + "Step 4 of 4 — Review & Create", +]; + +interface Props { + campaignId: number; + onSubmit: (data: CreateCharacterData) => Promise; + onClose: () => void; +} + +export default function CharacterWizard({ onSubmit, onClose }: Props) { + const [step, setStep] = useState(0); + + // Step 1 + const [name, setName] = useState(""); + const [charClass, setCharClass] = useState("Fighter"); + const [ancestry, setAncestry] = useState("Human"); + + // Step 2 + const [stats, setStats] = useState>(rollAllStats); + + // Step 3 + const [selectedBg, setSelectedBg] = useState(null); + const [alignment, setAlignment] = useState("Neutral"); + const [deity, setDeity] = useState(""); + + // Step 4 — locked-in rolled values + const [rolledHp, setRolledHp] = useState(0); + const [rolledGp, setRolledGp] = useState(0); + const [rolledGearSlots, setRolledGearSlots] = useState(10); + + const title = getStartingTitle(charClass, alignment); + + function handleRerollStats() { + setStats(rollAllStats()); + } + + function advanceToStep(nextStep: number) { + if (nextStep === 3 && step === 2) { + setRolledHp(rollStartingHp(charClass, stats.CON)); + setRolledGp(rollStartingGold()); + setRolledGearSlots(startingGearSlots(stats.STR)); + } + setStep(nextStep); + } + + async function handleCreate() { + await onSubmit({ + name: name.trim(), + class: charClass, + ancestry, + alignment, + background: selectedBg?.name ?? "", + deity: charClass === "Priest" ? deity : "", + title, + hp_max: rolledHp, + gp: rolledGp, + stats, + }); + } + + const reviewRows: [string, string][] = [ + ["Name", name], + ["Class", charClass], + ["Ancestry", ancestry], + ["Alignment", alignment], + ["Title", title || "—"], + ["Background", selectedBg?.name ?? "—"], + ["HP", String(rolledHp)], + ["Starting Gold", `${rolledGp} gp`], + ["Gear Slots", String(rolledGearSlots)], + ...(charClass === "Priest" && deity ? [["Deity", deity] as [string, string]] : []), + ]; + + return ( +
+
e.stopPropagation()}> +
New Character
+
{STEP_LABELS[step]}
+ +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ + {/* Step 1 */} + {step === 0 && ( + <> +
+ + setName(e.target.value)} + autoFocus + placeholder="Tharyn the Bold…" + /> +
+
+ + +
+
+ + +
+ + )} + + {/* Step 2 */} + {step === 1 && ( + <> +
+ {STAT_NAMES.map((s) => ( +
+ {s} + {stats[s]} + {formatModifier(getModifier(stats[s]))} +
+ ))} +
+ + + )} + + {/* Step 3 */} + {step === 2 && ( + <> +
+ +
+
+ {BACKGROUNDS.map((bg) => ( +
setSelectedBg(bg)} + > +
{bg.name}
+ {selectedBg?.name === bg.name && ( +
{bg.skill}
+ )} +
+ ))} +
+ +
+ + +
+ {charClass === "Priest" && ( +
+ + setDeity(e.target.value)} + placeholder="Saint Terragnis…" + /> +
+ )} + + )} + + {/* Step 4 — Review */} + {step === 3 && ( + <> +
Character
+
+ {reviewRows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
Ability Scores
+
+ {STAT_NAMES.map((s) => ( +
+
{s}
+
{stats[s]}
+
{formatModifier(getModifier(stats[s]))}
+
+ ))} +
+ + )} + +
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+
+ ); +}