Files
healer-man/src/ui/CharacterCreateScreen.tsx
T
2026-09-05 15:33:06 -04:00

260 lines
16 KiB
TypeScript

import { useMemo, useRef } from "react";
import {
APPEARANCE_KEYS,
FACTIONS,
classById,
classIconUrl,
classesForRace,
createDefaultAppearance,
raceById,
racesForCategory,
supportedGenders,
type AppearanceKey,
type GenderId,
type RaceId,
} from "../app/characterCatalog";
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
import { specializationsForClass } from "../game/specializationCatalog";
export function CharacterCreateScreen() {
const draft = useShellStore((state) => state.draft);
const notice = useShellStore((state) => state.notice);
const updateDraft = useShellStore((state) => state.updateDraft);
const setStep = useShellStore((state) => state.setCreatorStep);
const randomize = useShellStore((state) => state.randomizeDraft);
const createCharacter = useShellStore((state) => state.createDraftCharacter);
const cancel = useShellStore((state) => state.cancelCreator);
const nameRef = useRef<HTMLInputElement>(null);
const race = raceById(draft.raceId);
const characterClass = classById(draft.classId);
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
const availableClasses = useMemo(
() => classesForRace(draft.raceId, draft.categoryId),
[draft.categoryId, draft.raceId],
);
const appearanceLabels = race.labels[draft.gender];
const appearanceCounts = race.counts[draft.gender];
const specializationOptions = useMemo(() => specializationsForClass(draft.classId), [draft.classId]);
const chooseRace = (raceId: RaceId) => {
const nextClasses = classesForRace(raceId, draft.categoryId);
const nextGenders = supportedGenders(raceId);
const classId = nextClasses.some((definition) => definition.id === draft.classId)
? draft.classId
: nextClasses[0].id;
const gender = nextGenders.includes(draft.gender) ? draft.gender : nextGenders[0];
updateDraft({ raceId, classId, gender, appearance: createDefaultAppearance() });
};
const chooseCategory = (categoryId: ContentCategoryId) => updateDraft({ categoryId });
const chooseGender = (gender: GenderId) => {
if (!supportedGenders(draft.raceId).includes(gender)) return;
updateDraft({ gender, appearance: createDefaultAppearance() });
};
const adjustAppearance = (key: AppearanceKey, delta: number) => {
const count = appearanceCounts[key];
const value = (draft.appearance[key] + delta + count) % count;
updateDraft({ appearance: { ...draft.appearance, [key]: value } });
};
const goBack = () => draft.step === "appearance" ? setStep("identity") : cancel();
const actions = useMemo<MenuAction[]>(() => draft.step === "identity" ? [
...CONTENT_CATEGORIES.map((definition) => ({ id: `category-${definition.id}`, run: () => chooseCategory(definition.id) })),
...availableRaces.map((definition) => ({ id: `race-${definition.id}`, run: () => chooseRace(definition.id) })),
...supportedGenders(draft.raceId).map((gender) => ({
id: `gender-${gender}`,
run: () => chooseGender(gender),
})),
...availableClasses.map((definition) => ({ id: `class-${definition.id}`, run: () => updateDraft({ classId: definition.id }) })),
...specializationOptions.map((definition) => ({ id: `spec-${definition.id}`, run: () => updateDraft({ specializationId: definition.id }) })),
{ id: "rotate-left", run: () => updateDraft({ rotation: draft.rotation - 12 }) },
{ id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) },
{ id: "creator-back", run: cancel },
{ id: "creator-next", run: () => setStep("appearance") },
] : [
{ id: "character-name", run: () => nameRef.current?.focus() },
...APPEARANCE_KEYS.flatMap((key) => [
{ id: `${key}-minus`, run: () => adjustAppearance(key, -1) },
{ id: `${key}-plus`, run: () => adjustAppearance(key, 1) },
]),
{ id: "randomize", run: randomize },
{ id: "appearance-back", run: () => setStep("identity") },
{ id: "create-character", run: createCharacter },
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, specializationOptions, updateDraft]);
const controller = useMenuController(actions, {
columns: draft.step === "identity" ? 5 : 2,
initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name",
onBack: goBack,
});
return (
<DualDisplayFrame
contextLabel="Creation details"
top={
<FrontSurface className="character-create-screen" ariaLabel="Healer Man character creation">
<header className="creator-header">
<BrandMark compact />
<div><span>Character forge / Step {draft.step === "identity" ? "1" : "2"} of 2</span><strong>{draft.step === "identity" ? "Choose your path" : "Make them your own"}</strong></div>
{draft.step === "identity" && <nav className="creator-category-tabs" aria-label="Content category">
{CONTENT_CATEGORIES.map((category) => (
<ControllerButton key={category.id} controlId={`category-${category.id}`} selectedId={controller.selectedId} select={controller.select} className={draft.categoryId === category.id ? "is-active" : ""} type="button" onClick={() => chooseCategory(category.id)}><strong>{category.label}</strong><small>{category.name}</small></ControllerButton>
))}
</nav>}
<button type="button" onClick={cancel}>Cancel</button>
</header>
<div className="creator-progress" aria-hidden="true"><i className="is-complete" /><i className={draft.step === "appearance" ? "is-complete" : ""} /></div>
{draft.step === "identity" ? (
<>
<section className="creator-races">
<header><span>01</span><div><strong>Race</strong><small>{FACTIONS[race.faction].name}</small></div></header>
<div className="race-grid">
{availableRaces.map((definition) => (
<ControllerButton
key={definition.id}
controlId={`race-${definition.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`race-option ${draft.raceId === definition.id ? "is-active" : ""}`}
type="button"
onClick={() => chooseRace(definition.id)}
title={definition.description}
>
<b style={{ borderColor: definition.accent }}>{definition.sigil}</b><span>{definition.name}</span><small>{definition.faction === "taborea" ? "R" : definition.faction === "alliance" ? "A" : "H"}</small>
</ControllerButton>
))}
</div>
<div className="gender-picker">
{(["male", "female"] as const).map((gender) => {
const available = supportedGenders(draft.raceId).includes(gender);
return (
<ControllerButton
key={gender}
controlId={`gender-${gender}`}
selectedId={controller.selectedId}
select={controller.select}
className={draft.gender === gender ? "is-active" : ""}
type="button"
disabled={!available}
title={available ? undefined : `${race.name} has no ${gender} character model in its native source pack.`}
onClick={() => chooseGender(gender)}
>
{gender}
</ControllerButton>
);
})}
</div>
<p>{race.description}</p>
</section>
<main className="creator-preview-stage">
<CharacterPreview draft={draft} />
<div className="preview-rotate">
<ControllerButton controlId="rotate-left" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => updateDraft({ rotation: draft.rotation - 12 })} aria-label="Rotate character left">&#8634;</ControllerButton>
<span>Rotate preview</span>
<ControllerButton controlId="rotate-right" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => updateDraft({ rotation: draft.rotation + 12 })} aria-label="Rotate character right">&#8635;</ControllerButton>
</div>
</main>
<section className="creator-classes">
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}</small></div></header>
<div className="class-grid">
{availableClasses.map((definition) => (
<ControllerButton
key={definition.id}
controlId={`class-${definition.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`class-option ${draft.classId === definition.id ? "is-active" : ""}`}
type="button"
onClick={() => updateDraft({ classId: definition.id })}
title={`${definition.description} ${definition.specializations.join(" / ")}`.trim()}
>
<b style={{ color: definition.color }}><img src={classIconUrl(definition.id)} alt="" /></b><span><strong>{definition.name}</strong><small>{definition.role}</small></span>
{definition.mode === "conquest" && <i>CoA</i>}
</ControllerButton>
))}
</div>
<div className="class-summary">
<strong style={{ color: characterClass.color }}>{characterClass.name}</strong>
<span>{characterClass.description}</span>
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
</div>
{specializationOptions.length > 0 && (
<div className="creator-specializations" aria-label="Specialization">
<strong>Specialization</strong>
{specializationOptions.map((definition) => (
<ControllerButton
key={definition.id}
controlId={`spec-${definition.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={draft.specializationId === definition.id ? "is-active" : ""}
type="button"
onClick={() => updateDraft({ specializationId: definition.id })}
>
<span>{definition.displayName}</span>
<small>{PARTY_ROLE_LABELS[definition.role]} / {definition.talentTree}</small>
</ControllerButton>
))}
</div>
)}
</section>
<footer className="creator-footer">
<ControllerButton controlId="creator-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={cancel}>Back to roster</ControllerButton>
<ControllerButton controlId="creator-next" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => setStep("appearance")}><span>Customize &amp; name</span><small>Next step</small></ControllerButton>
</footer>
</>
) : (
<>
<main className="appearance-preview-stage">
<CharacterPreview draft={draft} />
<p><strong>{race.name} {characterClass.name}</strong><span>{draft.gender} / {FACTIONS[race.faction].name}</span></p>
</main>
<form className="appearance-controls" onSubmit={(event) => { event.preventDefault(); createCharacter(); }}>
<header><p className="front-kicker">Identity</p><h1>Name your adventurer</h1><small>Names follow the wow335a 2-12 letter limit.</small></header>
<label htmlFor="character-name">Character name</label>
<input
ref={nameRef}
id="character-name"
className={controller.isSelected("character-name") ? "is-controller-selected" : ""}
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
onFocus={() => controller.select("character-name")}
minLength={2}
maxLength={12}
pattern="[A-Za-z]+"
placeholder="Remedy"
autoComplete="off"
/>
<div className="customization-list">
{APPEARANCE_KEYS.map((key) => (
<div key={key} className="customization-row">
<span><strong>{appearanceLabels[key]}</strong><small>{draft.appearance[key] + 1} / {appearanceCounts[key]}</small></span>
<ControllerButton controlId={`${key}-minus`} selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => adjustAppearance(key, -1)} aria-label={`Previous ${appearanceLabels[key]}`}>-</ControllerButton>
<input type="range" min={0} max={appearanceCounts[key] - 1} value={draft.appearance[key]} onChange={(event) => updateDraft({ appearance: { ...draft.appearance, [key]: Number(event.target.value) } })} aria-label={appearanceLabels[key]} />
<ControllerButton controlId={`${key}-plus`} selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => adjustAppearance(key, 1)} aria-label={`Next ${appearanceLabels[key]}`}>+</ControllerButton>
</div>
))}
</div>
<div className="appearance-actions">
<ControllerButton controlId="randomize" selectedId={controller.selectedId} select={controller.select} className="text-action" type="button" onClick={randomize}>Randomize appearance</ControllerButton>
<ControllerButton controlId="appearance-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setStep("identity")}>Back</ControllerButton>
<ControllerButton controlId="create-character" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="submit"><span>Create character</span><small>Save to this roster</small></ControllerButton>
</div>
{notice && <p className="front-notice" role="status">{notice}</p>}
</form>
</>
)}
<ControllerLegend back />
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
/>
);
}