new help section explaining classes and their mechanics
This commit is contained in:
+175
-31
@@ -13,6 +13,7 @@ import {
|
||||
} from "../frontend/profileSections";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER, isHealerClassId } from "../game/healers";
|
||||
import { HEALER_GUIDES } from "../game/healerGuides";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
|
||||
@@ -77,14 +78,15 @@ function ControllerButton({
|
||||
onClick,
|
||||
onPointerEnter,
|
||||
onFocus: _onFocus,
|
||||
trackPointer = true,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; selectedId: string; select: (id: string) => void }) {
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; selectedId: string; select: (id: string) => void; trackPointer?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`${className} ${selectedId === id ? "is-controller-selected" : ""}`}
|
||||
onClick={(event) => { select(id); onClick?.(event); }}
|
||||
onPointerEnter={(event) => { select(id); onPointerEnter?.(event); }}
|
||||
onPointerEnter={(event) => { if (trackPointer) select(id); onPointerEnter?.(event); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -503,39 +505,46 @@ function SaveScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
|
||||
{ id: "roguelike-pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" },
|
||||
{ id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
|
||||
{ id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
|
||||
{ id: "hockey-healing", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" },
|
||||
{ id: "hockey-healing-pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" },
|
||||
{ id: "blockbreaker", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" },
|
||||
{ id: "aether-assault", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" },
|
||||
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||
const HOME_MODES: { id: GameModeId; category: "pve" | "pvp"; icon: string; label: string; copy: string }[] = [
|
||||
{ id: "roguelike-pve", category: "pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" },
|
||||
{ id: "rogue-trials", category: "pve", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
|
||||
{ id: "dungeons", category: "pve", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
|
||||
{ id: "hockey-healing", category: "pve", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" },
|
||||
{ id: "hockey-healing-pvp", category: "pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" },
|
||||
{ id: "blockbreaker", category: "pve", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" },
|
||||
{ id: "aether-assault", category: "pve", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" },
|
||||
{ id: "roguelike-pvp", category: "pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||
{ id: "stadium-pvp", category: "pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||
];
|
||||
|
||||
const HOME_MODE_SECTIONS = [
|
||||
{ id: "pve", title: "PVE Modes", copy: "Cooperative expeditions", modes: HOME_MODES.filter((mode) => mode.category === "pve") },
|
||||
{ id: "pvp", title: "PVP Modes", copy: "Competitive arenas", modes: HOME_MODES.filter((mode) => mode.category === "pvp") },
|
||||
] as const;
|
||||
|
||||
function HomeScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const accountId = useFrontendStore((state) => state.accountId);
|
||||
const selectMode = useFrontendStore((state) => state.selectMode);
|
||||
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
|
||||
const openAppearanceLab = useFrontendStore((state) => state.openAppearanceLab);
|
||||
const openClassHelp = useFrontendStore((state) => state.openClassHelp);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "hockey-healing" } },
|
||||
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "hockey-healing-pvp" } },
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "blockbreaker" } },
|
||||
{ id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { right: "hockey-healing-pvp", up: "roguelike-pve", down: "roguelike-pvp" } },
|
||||
{ id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "hockey-healing", right: "blockbreaker", up: "rogue-trials", down: "stadium-pvp" } },
|
||||
{ id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "hockey-healing-pvp", up: "dungeons", down: "aether-assault" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { right: "stadium-pvp", up: "hockey-healing", down: "profile" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", right: "aether-assault", up: "hockey-healing-pvp", down: "gear" } },
|
||||
{ id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "stadium-pvp", up: "blockbreaker", down: "settings" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "appearance" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "stadium-pvp", left: "profile", down: "settings" } },
|
||||
{ id: "appearance", run: openAppearanceLab, neighbors: { up: "profile", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "gear", left: "appearance", down: "class-druid" } },
|
||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { left: "roguelike-pve", right: "rogue-trials", down: "dungeons" } },
|
||||
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "hockey-healing-pvp", down: "hockey-healing" } },
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "dungeons", right: "hockey-healing", up: "roguelike-pve", down: "blockbreaker" } },
|
||||
{ id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { left: "dungeons", right: "roguelike-pvp", up: "rogue-trials", down: "aether-assault" } },
|
||||
{ id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "blockbreaker", right: "aether-assault", up: "dungeons", down: "profile" } },
|
||||
{ id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "blockbreaker", right: "stadium-pvp", up: "hockey-healing", down: "appearance" } },
|
||||
{ id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "rogue-trials", right: "hockey-healing-pvp", down: "roguelike-pvp" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "hockey-healing", right: "roguelike-pvp", up: "hockey-healing-pvp", down: "stadium-pvp" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "aether-assault", right: "stadium-pvp", up: "roguelike-pvp", down: "class-help" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "blockbreaker", left: "class-help", right: "gear", down: "class-priest" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "aether-assault", left: "profile", right: "appearance", down: "class-druid" } },
|
||||
{ id: "appearance", run: openAppearanceLab, neighbors: { up: "aether-assault", left: "gear", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "appearance", right: "class-help", down: "class-druid" } },
|
||||
{ id: "class-help", run: openClassHelp, neighbors: { up: "stadium-pvp", left: "settings", right: "profile", down: "class-chronomancer" } },
|
||||
...HEALER_CLASS_ORDER.map((classId, index) => ({
|
||||
id: `class-${classId}`,
|
||||
run: () => selectHealerClass(classId),
|
||||
@@ -547,7 +556,7 @@ function HomeScreen() {
|
||||
},
|
||||
})),
|
||||
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-chronomancer" } },
|
||||
], [navigate, openAppearanceLab, selectHealerClass, selectMode]);
|
||||
], [navigate, openAppearanceLab, openClassHelp, selectHealerClass, selectMode]);
|
||||
const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") });
|
||||
if (!hunter) return null;
|
||||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||||
@@ -558,11 +567,18 @@ function HomeScreen() {
|
||||
top={
|
||||
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
||||
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
||||
<div className="mode-grid">
|
||||
{HOME_MODES.map((mode) => (
|
||||
<ControllerButton key={mode.id} id={mode.id} selectedId={controller.selectedId} select={controller.select} className={`mode-card mode-card-${mode.id}`} onClick={() => selectMode(mode.id)}>
|
||||
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b>›</b>
|
||||
</ControllerButton>
|
||||
<div className="home-mode-sections">
|
||||
{HOME_MODE_SECTIONS.map((section) => (
|
||||
<section key={section.id} className={`home-mode-section is-${section.id}`} aria-labelledby={`home-${section.id}-heading`}>
|
||||
<header><span><small>{section.copy}</small><strong id={`home-${section.id}-heading`}>{section.title}</strong></span><b>{section.modes.length} ACTIVITIES</b></header>
|
||||
<div className={`mode-grid mode-grid-${section.id}`}>
|
||||
{section.modes.map((mode) => (
|
||||
<ControllerButton key={mode.id} id={mode.id} selectedId={controller.selectedId} select={controller.select} className={`mode-card mode-card-${mode.id}`} onClick={() => selectMode(mode.id)}>
|
||||
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b>›</b>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-secondary-actions">
|
||||
@@ -570,6 +586,7 @@ function HomeScreen() {
|
||||
<ControllerButton id="gear" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="appearance" selectedId={controller.selectedId} select={controller.select} onClick={openAppearanceLab}><i>♜</i><span><strong>Appearance Lab</strong><small>Build and preview your healer</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="settings" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("settings")}><i>⚙</i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="class-help" selectedId={controller.selectedId} select={controller.select} onClick={openClassHelp}><i>?</i><span><strong>Class Help</strong><small>Abilities, rotations, synergies</small></span><b>›</b></ControllerButton>
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
@@ -600,6 +617,132 @@ function HomeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClassHelpScreen() {
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const classId = useFrontendStore((state) => state.guideClassId);
|
||||
const selectedAbilityId = useFrontendStore((state) => state.guideAbilityId);
|
||||
const selectClass = useFrontendStore((state) => state.selectGuideClass);
|
||||
const selectAbility = useFrontendStore((state) => state.selectGuideAbility);
|
||||
const healer = HEALER_CLASSES[classId];
|
||||
const guide = HEALER_GUIDES[classId];
|
||||
const selectedAbility = healer.abilities[selectedAbilityId];
|
||||
const selectedGuide = guide.abilityGuides[selectedAbilityId];
|
||||
const classIndex = HEALER_CLASS_ORDER.indexOf(classId);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "guide-back", run: () => navigate("home"), neighbors: { right: "guide-class-priest", down: "guide-class-priest" } },
|
||||
...HEALER_CLASS_ORDER.map((candidate, index) => ({
|
||||
id: `guide-class-${candidate}`,
|
||||
run: () => selectClass(candidate),
|
||||
neighbors: {
|
||||
left: `guide-class-${HEALER_CLASS_ORDER[index === 0 ? HEALER_CLASS_ORDER.length - 1 : index - 1]}`,
|
||||
right: `guide-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
up: "guide-back",
|
||||
down: `guide-${ABILITY_ORDER[Math.min(index, ABILITY_ORDER.length - 1)]}`,
|
||||
},
|
||||
})),
|
||||
...ABILITY_ORDER.map((abilityId, index) => ({
|
||||
id: `guide-${abilityId}`,
|
||||
run: () => selectAbility(abilityId),
|
||||
neighbors: {
|
||||
left: `guide-${ABILITY_ORDER[index % 3 === 0 ? index + 2 : index - 1]}`,
|
||||
right: `guide-${ABILITY_ORDER[index % 3 === 2 ? index - 2 : index + 1]}`,
|
||||
up: index < 3 ? `guide-class-${classId}` : `guide-${ABILITY_ORDER[index - 3]}`,
|
||||
down: index < 3 ? `guide-${ABILITY_ORDER[index + 3]}` : "guide-back",
|
||||
},
|
||||
})),
|
||||
], [classId, navigate, selectAbility, selectClass]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: `guide-class-${classId}`,
|
||||
onBack: () => navigate("home"),
|
||||
});
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
<FrontSurface
|
||||
className="class-help-surface"
|
||||
ariaLabel="Healing class guide"
|
||||
>
|
||||
<header className="front-screen-header class-help-header">
|
||||
<BrandMark compact />
|
||||
<div><span>Field manual</span><h1>Class Help</h1></div>
|
||||
<small>{classIndex + 1} / {HEALER_CLASS_ORDER.length}</small>
|
||||
<ControllerButton id="guide-back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton>
|
||||
</header>
|
||||
<nav className="guide-class-tabs" aria-label="Healing classes">
|
||||
{HEALER_CLASS_ORDER.map((candidate) => {
|
||||
const candidateHealer = HEALER_CLASSES[candidate];
|
||||
return (
|
||||
<ControllerButton
|
||||
key={candidate}
|
||||
id={`guide-class-${candidate}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={candidate === classId ? "is-selected" : ""}
|
||||
style={{ "--guide-color": candidateHealer.color } as React.CSSProperties}
|
||||
aria-pressed={candidate === classId}
|
||||
onClick={() => selectClass(candidate)}
|
||||
>
|
||||
<i>{candidateHealer.icon}</i><span><strong>{candidateHealer.name}</strong><small>{HEALER_GUIDES[candidate].role}</small></span>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<section className="guide-class-intro" style={{ "--guide-color": healer.color } as React.CSSProperties}>
|
||||
<i>{healer.icon}</i>
|
||||
<span><small>{healer.specialization} · {guide.learningCurve}</small><h2>{guide.role}</h2><p>{healer.description}</p></span>
|
||||
<b>{healer.secondaryResourceName ? `${healer.resourceName} + ${healer.secondaryResourceName}` : healer.resourceName}</b>
|
||||
</section>
|
||||
<div className="guide-ability-grid" aria-label={`${healer.name} abilities`}>
|
||||
{ABILITY_ORDER.map((abilityId) => {
|
||||
const ability = healer.abilities[abilityId];
|
||||
return (
|
||||
<ControllerButton
|
||||
key={abilityId}
|
||||
id={`guide-${abilityId}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={`guide-ability-card ${selectedAbilityId === abilityId ? "is-selected" : ""}`}
|
||||
style={{ "--ability-color": ability.color } as React.CSSProperties}
|
||||
aria-pressed={selectedAbilityId === abilityId}
|
||||
trackPointer={false}
|
||||
onClick={() => selectAbility(abilityId)}
|
||||
>
|
||||
<span className="guide-ability-icon"><b>{ability.icon}</b><small>{ability.gamepad}</small></span>
|
||||
<span className="guide-ability-copy"><strong>{ability.name}</strong><small>{ability.description}</small></span>
|
||||
<span className="guide-ability-meta"><b>{ability.cooldown ? `${ability.cooldown}s CD` : "No cooldown"}</b><b>{ability.mana} {healer.resourceName}</b><b>{ability.targeting}</b></span>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="class-help-context" bottom ariaLabel={`${healer.name} play guide`}>
|
||||
<header className="context-header"><span>{healer.name} field guide</span><b>{guide.learningCurve.toUpperCase()}</b></header>
|
||||
<section className="guide-context-class" style={{ "--guide-color": healer.color } as React.CSSProperties}>
|
||||
<i>{healer.icon}</i><span><small>{healer.specialization}</small><h2>{guide.role}</h2><p>{guide.resourceGuide}</p></span>
|
||||
</section>
|
||||
<section className="guide-core-loop">
|
||||
<header><span>Core loop</span><b>01—03</b></header>
|
||||
{guide.coreLoop.map((step, index) => <p key={step}><i>0{index + 1}</i><span>{step}</span></p>)}
|
||||
</section>
|
||||
<section className="guide-selected-detail" style={{ "--ability-color": selectedAbility.color } as React.CSSProperties}>
|
||||
<header><i>{selectedAbility.icon}</i><span><small>Selected ability · {selectedAbility.gamepad}</small><h3>{selectedAbility.name}</h3></span></header>
|
||||
<p>{selectedGuide.useWhen}</p>
|
||||
<aside><b>Field tip</b><span>{selectedGuide.fieldTip}</span></aside>
|
||||
<div className="guide-synergy-list"><span>Synergies</span>{selectedGuide.synergies.map((synergy) => {
|
||||
const pairedAbility = healer.abilities[synergy.with];
|
||||
return <article key={`${synergy.with}-${synergy.summary}`}><i>{pairedAbility.icon}</i><span><strong>{pairedAbility.name}</strong><small>{synergy.summary}</small></span><b>{synergy.kind === "mechanic" ? "DIRECT" : "COMBO"}</b></article>;
|
||||
})}</div>
|
||||
</section>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const accountId = useFrontendStore((state) => state.accountId);
|
||||
@@ -1718,6 +1861,7 @@ export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[],
|
||||
if (screen === "login") return <LoginScreen />;
|
||||
if (screen === "saves") return <SaveScreen />;
|
||||
if (screen === "home") return <HomeScreen />;
|
||||
if (screen === "class-help") return <ClassHelpScreen />;
|
||||
if (screen === "profile") return <ProfileScreen />;
|
||||
if (screen === "gear") return <GearScreen />;
|
||||
if (screen === "appearance") return <AppearanceScreen />;
|
||||
|
||||
Reference in New Issue
Block a user