forked from phenom/healer-man
260 lines
16 KiB
TypeScript
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">↺</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">↻</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 & 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 />}
|
|
/>
|
|
);
|
|
}
|