1872 lines
128 KiB
TypeScript
1872 lines
128 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
|
||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
|
||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||
import type { GameModeId, ProfileStatId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||
import {
|
||
PROFILE_SECTIONS,
|
||
alphabeticalBosses,
|
||
defaultStatForSection,
|
||
isBossProfileStat,
|
||
profileSectionForStat,
|
||
} 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";
|
||
import { RUN_BUFFS, formatRunBuffEffect, selectRandomBossPair } from "../game/roguelike";
|
||
import type { BossId } from "../game/types";
|
||
import {
|
||
GEAR_OWNER_LABELS,
|
||
GEAR_OWNER_ORDER,
|
||
GEAR_RECIPES,
|
||
GEAR_SLOT_LABELS,
|
||
GEAR_SLOT_ORDER,
|
||
GEAR_STAT_LABELS,
|
||
MAX_GEAR_LEVEL,
|
||
canAffordGearUpgrade,
|
||
canUpgradeGearSlot,
|
||
gearBonusText,
|
||
gearUpgradeCosts,
|
||
type GearOwnerId,
|
||
} from "../game/progression/gear";
|
||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
|
||
import {
|
||
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||
PASSIVE_INFUSIONS,
|
||
PASSIVE_INFUSION_MIN_GEAR_LEVEL,
|
||
activeInfusionUnlocked,
|
||
infusionCosts,
|
||
infusionsForOwner,
|
||
passiveInfusionUnlocked,
|
||
} from "../game/progression/infusions";
|
||
import { requestDisplaySurface } from "../platform/displayRouting";
|
||
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
|
||
import { leaderboardCache } from "../frontend/leaderboardCache";
|
||
import { hasPendingSaveSync, networkAppearsOnline } from "../frontend/saveSync";
|
||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
|
||
import {
|
||
HOCKEY_PVP_GOAL_DAMAGE,
|
||
HOCKEY_PVP_QUEUE_TIMEOUT_MS,
|
||
hockeyPvpBossAt,
|
||
randomHockeyPvpCpuName,
|
||
type HockeyPvpMatchConfig,
|
||
} from "../game/hockeyHealingPvp";
|
||
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
||
import {
|
||
APPEARANCE_SLOT_DEFINITIONS,
|
||
appearanceSlotEnabled,
|
||
appearanceSlotLabel,
|
||
appearancesMatch,
|
||
cycleAppearanceSlot,
|
||
type AppearanceSlotId,
|
||
} from "../game/appearanceLab";
|
||
import { CHARACTER_MODEL_MODE, type CharacterAppearanceV1 } from "../game/characterAppearance";
|
||
import { createDefaultHealerAppearance } from "../game/healerVisuals";
|
||
|
||
const HealerAppearancePreview = lazy(() => import("./GameScene").then((module) => ({ default: module.HealerAppearancePreview })));
|
||
|
||
function ControllerButton({
|
||
id,
|
||
selectedId,
|
||
select,
|
||
className = "",
|
||
onClick,
|
||
onPointerEnter,
|
||
onFocus: _onFocus,
|
||
trackPointer = true,
|
||
...props
|
||
}: 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) => { if (trackPointer) select(id); onPointerEnter?.(event); }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function FrontSurface({ className = "", children, bottom = false, ariaLabel }: { className?: string; children: React.ReactNode; bottom?: boolean; ariaLabel: string }) {
|
||
return (
|
||
<section className={`display front-surface ${bottom ? "bottom-display front-bottom" : "top-display front-top"} ${className}`} aria-label={ariaLabel}>
|
||
<div className="front-grain" aria-hidden="true" />
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function BrandMark({ compact = false }: { compact?: boolean }) {
|
||
return (
|
||
<div className={`front-brand ${compact ? "is-compact" : ""}`}>
|
||
<span className="brand-sigil">✦</span>
|
||
<span><small>Healers answer the call</small><strong>I Want To Heal</strong></span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ControllerLegend({ back = false }: { back?: boolean }) {
|
||
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
||
}
|
||
|
||
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
|
||
return (
|
||
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
|
||
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
|
||
<div className="login-save-list">
|
||
{slots.map((slot) => {
|
||
const save = slot.local ?? slot.online;
|
||
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||
return (
|
||
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
|
||
<b>{String(slot.id).padStart(2, "0")}</b>
|
||
{save ? (
|
||
<>
|
||
<div className="login-save-avatar">{save.hunterName[0]}</div>
|
||
<span>
|
||
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
|
||
<strong>{save.hunterName}</strong>
|
||
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
|
||
</span>
|
||
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
|
||
</>
|
||
) : (
|
||
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
|
||
</FrontSurface>
|
||
);
|
||
}
|
||
|
||
function LoginScreen() {
|
||
const restoreSession = useFrontendStore((state) => state.restoreSession);
|
||
const signIn = useFrontendStore((state) => state.signIn);
|
||
const createAccount = useFrontendStore((state) => state.createAccount);
|
||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||
const slots = useFrontendStore((state) => state.slots);
|
||
const accountId = useFrontendStore((state) => state.accountId);
|
||
const notice = useFrontendStore((state) => state.notice);
|
||
const [username, setUsername] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const usernameRef = useRef<HTMLInputElement>(null);
|
||
const passwordRef = useRef<HTMLInputElement>(null);
|
||
const restoreStarted = useRef(false);
|
||
useEffect(() => {
|
||
if (restoreStarted.current) return;
|
||
restoreStarted.current = true;
|
||
void restoreSession();
|
||
}, [restoreSession]);
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
{ id: "username", run: () => usernameRef.current?.focus() },
|
||
{ id: "password", run: () => passwordRef.current?.focus() },
|
||
{ id: "sign-in", run: () => { void signIn(username, password); } },
|
||
{ id: "create-account", run: () => { void createAccount(username, password); } },
|
||
{ id: "continue", run: continueOffline },
|
||
], [continueOffline, createAccount, password, signIn, username]);
|
||
const controller = useMenuController(actions);
|
||
|
||
const submitSignIn = () => { void signIn(username, password); };
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className="login-surface" ariaLabel="I Want To Heal login">
|
||
<div className="login-aura" aria-hidden="true"><i /><b>+</b><i /></div>
|
||
<BrandMark />
|
||
<div className="login-copy">
|
||
<span>Offline-first hunter records</span>
|
||
<h1>Keep everyone standing.</h1>
|
||
<p>Continue to your saved hunters. Sign in when you want online copies for PC ↔ AYN Thor handoff.</p>
|
||
</div>
|
||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||
<label htmlFor="account-username">Username</label>
|
||
<input
|
||
ref={usernameRef}
|
||
id="account-username"
|
||
className={controller.selectedId === "username" ? "is-controller-selected" : ""}
|
||
value={username}
|
||
onChange={(event) => setUsername(event.target.value)}
|
||
onFocus={() => controller.select("username")}
|
||
autoComplete="username"
|
||
maxLength={20}
|
||
minLength={3}
|
||
pattern="[A-Za-z0-9_]+"
|
||
required
|
||
/>
|
||
<label htmlFor="account-password">Password</label>
|
||
<input
|
||
ref={passwordRef}
|
||
id="account-password"
|
||
className={controller.selectedId === "password" ? "is-controller-selected" : ""}
|
||
type="password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
onFocus={() => controller.select("password")}
|
||
autoComplete="current-password"
|
||
maxLength={128}
|
||
minLength={10}
|
||
required
|
||
/>
|
||
<ControllerButton id="sign-in" selectedId={controller.selectedId} select={controller.select} className="front-primary" type="submit">
|
||
<span>Sign in & sync</span><small>Online saves enabled</small>
|
||
</ControllerButton>
|
||
<ControllerButton id="create-account" selectedId={controller.selectedId} select={controller.select} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
|
||
<span>Create account</span><small>Required for first sync</small>
|
||
</ControllerButton>
|
||
<ControllerButton id="continue" selectedId={controller.selectedId} select={controller.select} className="front-secondary" type="button" onClick={continueOffline}>
|
||
<span>Continue</span><small>Choose saved hunter</small>
|
||
</ControllerButton>
|
||
</form>
|
||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||
<ControllerLegend />
|
||
</FrontSurface>
|
||
}
|
||
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SlotCard({ slot, selected, controllerSelected, onSelect, onHover }: { slot: SaveSlotState; selected: boolean; controllerSelected: boolean; onSelect: () => void; onHover: () => void }) {
|
||
const continuation = resolveSaveContinuation(slot);
|
||
const save = slot.local ?? slot.online;
|
||
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
|
||
return (
|
||
<button className={`save-slot ${selected ? "is-selected" : ""} ${controllerSelected ? "is-controller-selected" : ""}`} onClick={onSelect} onPointerEnter={onHover}>
|
||
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
|
||
{save ? (
|
||
<>
|
||
<div className="slot-portrait">{save.hunterName[0]}<i>✦</i></div>
|
||
<span className="slot-name"><strong>{save.hunterName}</strong><small>Level {save.healers[save.activeClassId].level} · {healer?.name}</small></span>
|
||
<span className="slot-location">{save.location}</span>
|
||
<span className="slot-meta"><b>{formatPlayTime(save.playSeconds)}</b><small>{formatSaveTimestamp(save.updatedAt)}</small></span>
|
||
</>
|
||
) : (
|
||
<div className="empty-slot"><b>+</b><strong>New hunter</strong><small>Empty offline slot</small></div>
|
||
)}
|
||
<i className="selection-chevron">›</i>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SaveScreen() {
|
||
const slots = useFrontendStore((state) => state.slots);
|
||
const accountId = useFrontendStore((state) => state.accountId);
|
||
const selectedSlotId = useFrontendStore((state) => state.selectedSlotId);
|
||
const notice = useFrontendStore((state) => state.notice);
|
||
const selectSlot = useFrontendStore((state) => state.selectSlot);
|
||
const createSlot = useFrontendStore((state) => state.createSlot);
|
||
const playSlot = useFrontendStore((state) => state.playSlot);
|
||
const uploadSlot = useFrontendStore((state) => state.uploadSlot);
|
||
const downloadSlot = useFrontendStore((state) => state.downloadSlot);
|
||
const copySlot = useFrontendStore((state) => state.copySlot);
|
||
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
|
||
const navigate = useFrontendStore((state) => state.navigate);
|
||
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
|
||
const [hunterName, setHunterName] = useState("");
|
||
const hunterNameRef = useRef<HTMLInputElement>(null);
|
||
const [resolvingOnline, setResolvingOnline] = useState(false);
|
||
const [versionError, setVersionError] = useState("");
|
||
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
||
const hasLocal = Boolean(selected.local);
|
||
const hasOnline = Boolean(selected.online);
|
||
const continuation = resolveSaveContinuation(selected);
|
||
const primaryActionId = continuation === "create" ? "create" : "play";
|
||
|
||
const finishCreation = () => {
|
||
if (createSlot(selectedSlotId, hunterName)) {
|
||
setHunterName("");
|
||
setDialog(null);
|
||
}
|
||
};
|
||
const openCreation = () => {
|
||
setHunterName("");
|
||
setDialog("create");
|
||
requestDisplaySurface("top");
|
||
};
|
||
const openSaveDialog = (nextDialog: "copy" | "delete") => {
|
||
setDialog(nextDialog);
|
||
requestDisplaySurface("top");
|
||
};
|
||
|
||
const continueWithOnline = async () => {
|
||
if (resolvingOnline) return;
|
||
setResolvingOnline(true);
|
||
setVersionError("");
|
||
await downloadSlot(selectedSlotId);
|
||
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
|
||
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
|
||
setDialog(null);
|
||
setResolvingOnline(false);
|
||
playSlot(selectedSlotId);
|
||
return;
|
||
}
|
||
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
|
||
setResolvingOnline(false);
|
||
};
|
||
|
||
const continueSelected = () => {
|
||
if (continuation === "create") return openCreation();
|
||
if (continuation === "local") return playSlot(selectedSlotId);
|
||
if (continuation === "online") {
|
||
void continueWithOnline();
|
||
return;
|
||
}
|
||
setVersionError("");
|
||
setDialog("version");
|
||
requestDisplaySurface("top");
|
||
};
|
||
|
||
const actions = useMemo<MenuAction[]>(() => dialog === "version"
|
||
? [
|
||
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
|
||
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
|
||
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
|
||
]
|
||
: dialog === "create"
|
||
? [
|
||
{ id: "hunter-name", run: () => hunterNameRef.current?.focus() },
|
||
{ id: "confirm-create", run: finishCreation, enabled: Boolean(normalizeHunterName(hunterName)) },
|
||
{ id: "cancel-create", run: () => setDialog(null) },
|
||
]
|
||
: dialog === "copy"
|
||
? slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ({ id: `copy-${slot.id}`, run: () => { copySlot(selectedSlotId, slot.id); setDialog(null); } }))
|
||
: dialog === "delete"
|
||
? [
|
||
{ id: "confirm-delete", run: () => { deleteSlot(selectedSlotId); setDialog(null); } },
|
||
{ id: "cancel-delete", run: () => setDialog(null) },
|
||
]
|
||
: [
|
||
...slots.map((slot, index) => ({
|
||
id: `slot-${slot.id}`,
|
||
run: () => selectSlot(slot.id),
|
||
neighbors: {
|
||
left: `slot-${slots[Math.max(0, index - 1)].id}`,
|
||
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
|
||
down: primaryActionId,
|
||
},
|
||
})),
|
||
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
|
||
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
|
||
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
|
||
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
|
||
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
|
||
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
|
||
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
|
||
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
|
||
|
||
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className={`save-surface ${dialog ? "has-dialog" : ""}`} ariaLabel="Save slots">
|
||
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter records</span><h1>Choose a save</h1></div><b className={accountId ? "is-online" : ""}>{accountId ? `● ${accountId}` : "○ OFFLINE"}</b></header>
|
||
<div className="save-slot-grid">
|
||
{slots.map((slot) => (
|
||
<SlotCard
|
||
key={slot.id}
|
||
slot={slot}
|
||
selected={selectedSlotId === slot.id}
|
||
controllerSelected={controller.isSelected(`slot-${slot.id}`)}
|
||
onSelect={() => { controller.select(`slot-${slot.id}`); selectSlot(slot.id); }}
|
||
onHover={() => controller.select(`slot-${slot.id}`)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="save-top-actions">
|
||
<ControllerButton id={primaryActionId} selectedId={controller.selectedId} select={controller.select} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
|
||
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
|
||
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
|
||
</ControllerButton>
|
||
<ControllerButton id="upload" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device → server</small></ControllerButton>
|
||
<ControllerButton id="download" selectedId={controller.selectedId} select={controller.select} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server → device</small></ControllerButton>
|
||
<ControllerButton id="copy" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></ControllerButton>
|
||
<ControllerButton id="delete" selectedId={controller.selectedId} select={controller.select} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></ControllerButton>
|
||
<ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></ControllerButton>
|
||
</div>
|
||
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
|
||
{dialog && (
|
||
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
|
||
{dialog === "version" && selected.local && selected.online ? (
|
||
<div className="version-choice-dialog">
|
||
<span>Newer online save found</span>
|
||
<h2>Which save do you want?</h2>
|
||
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
|
||
<div className="version-comparison">
|
||
<article className="is-newer">
|
||
<header><span>Online copy</span><b>NEWER</b></header>
|
||
<strong>{selected.online.hunterName}</strong>
|
||
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
|
||
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
|
||
</article>
|
||
<article>
|
||
<header><span>Device copy</span><b>OFFLINE</b></header>
|
||
<strong>{selected.local.hunterName}</strong>
|
||
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
|
||
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
|
||
</article>
|
||
</div>
|
||
<div className="dialog-actions version-actions">
|
||
<ControllerButton id="version-online" selectedId={controller.selectedId} select={controller.select} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></ControllerButton>
|
||
<ControllerButton id="version-local" selectedId={controller.selectedId} select={controller.select} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></ControllerButton>
|
||
<ControllerButton id="cancel-version" selectedId={controller.selectedId} select={controller.select} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</ControllerButton>
|
||
</div>
|
||
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
|
||
</div>
|
||
) : dialog === "create" ? (
|
||
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
|
||
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
|
||
<label htmlFor="new-hunter-name">Hunter name</label>
|
||
<input
|
||
ref={hunterNameRef}
|
||
id="new-hunter-name"
|
||
className={controller.selectedId === "hunter-name" ? "is-controller-selected" : ""}
|
||
value={hunterName}
|
||
maxLength={MAX_HUNTER_NAME_LENGTH}
|
||
autoComplete="off"
|
||
onFocus={() => controller.select("hunter-name")}
|
||
onChange={(event) => setHunterName(event.target.value)}
|
||
onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }}
|
||
placeholder="Enter name"
|
||
/>
|
||
<small>{normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}</small>
|
||
<div className="dialog-actions">
|
||
<ControllerButton id="confirm-create" selectedId={controller.selectedId} select={controller.select} className="front-primary" type="submit" disabled={!normalizeHunterName(hunterName)}>Create hunter</ControllerButton>
|
||
<ControllerButton id="cancel-create" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => setDialog(null)}>Cancel</ControllerButton>
|
||
</div>
|
||
</form>
|
||
) : dialog === "copy" ? (
|
||
<>
|
||
<span>Copy local save</span><h2>Choose destination</h2><p>Destination local save will be overwritten. Online copies stay unchanged.</p>
|
||
<div className="dialog-actions">
|
||
{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => (
|
||
<ControllerButton key={slot.id} id={`copy-${slot.id}`} selectedId={controller.selectedId} select={controller.select} onClick={() => { copySlot(selectedSlotId, slot.id); setDialog(null); }}>
|
||
Slot {slot.id}<small>{slot.local ? "Overwrite" : "Empty"}</small>
|
||
</ControllerButton>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span>Delete local save</span><h2>Erase slot {selectedSlotId}?</h2><p>Device copy will be removed. Existing online version remains available for download.</p>
|
||
<div className="dialog-actions">
|
||
<ControllerButton id="confirm-delete" selectedId={controller.selectedId} select={controller.select} className="is-danger" onClick={() => { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local</ControllerButton>
|
||
<ControllerButton id="cancel-delete" selectedId={controller.selectedId} select={controller.select} onClick={() => setDialog(null)}>Cancel</ControllerButton>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
|
||
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
|
||
<div className="selected-save-summary">
|
||
{selected.local ?? selected.online ? (
|
||
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
|
||
) : (
|
||
<><div className="summary-avatar is-empty">+</div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
|
||
)}
|
||
</div>
|
||
{(selected.local ?? selected.online) && (() => {
|
||
const save = (selected.local ?? selected.online)!;
|
||
const healer = HEALER_CLASSES[save.activeClassId];
|
||
return (
|
||
<>
|
||
<div className="save-dossier-stats">
|
||
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
|
||
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
|
||
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
|
||
</div>
|
||
<div className="save-dossier-records">
|
||
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
|
||
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
<div className="save-copy-state">
|
||
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
|
||
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
|
||
</div>
|
||
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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: { 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),
|
||
neighbors: {
|
||
left: `class-${HEALER_CLASS_ORDER[index % 3 === 0 ? Math.min(index + 2, HEALER_CLASS_ORDER.length - 1) : index - 1]}`,
|
||
right: `class-${HEALER_CLASS_ORDER[index % 3 === 2 || index === HEALER_CLASS_ORDER.length - 1 ? index - (index % 3) : index + 1]}`,
|
||
up: index < 3 ? (["appearance", "settings", "settings"] as const)[index] : `class-${HEALER_CLASS_ORDER[index - 3]}`,
|
||
down: index + 3 < HEALER_CLASS_ORDER.length ? `class-${HEALER_CLASS_ORDER[index + 3]}` : "change-save",
|
||
},
|
||
})),
|
||
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-chronomancer" } },
|
||
], [navigate, openAppearanceLab, openClassHelp, selectHealerClass, selectMode]);
|
||
const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") });
|
||
if (!hunter) return null;
|
||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||
const activeProgress = hunter.healers[hunter.activeClassId];
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
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="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">
|
||
<ControllerButton id="profile" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("profile")}><i>♙</i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b>›</b></ControllerButton>
|
||
<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>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="hunter-context" bottom ariaLabel="Active hunter summary">
|
||
<header className="context-header"><span>Active hunter</span><b>LOCAL AUTOSAVE</b></header>
|
||
<div className="hunter-card">
|
||
<div className="hunter-crest">{hunter.hunterName[0]}<i style={{ background: activeHealer.color }}>{activeHealer.icon}</i></div>
|
||
<span><small>Level {activeProgress.level} · {activeHealer.specialization}</small><h2>{hunter.hunterName}</h2><p>{hunter.location}</p></span>
|
||
</div>
|
||
<div className="hunter-stat-row">
|
||
<span><small>Boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
|
||
<span><small>Flawless</small><strong>{hunter.stats.flawlessClears}</strong></span>
|
||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||
</div>
|
||
<div className="healer-picker"><span>Choose healer</span><div>{HEALER_CLASS_ORDER.map((classId) => {
|
||
const healer = HEALER_CLASSES[classId];
|
||
const progress = hunter.healers[classId];
|
||
return <ControllerButton key={classId} id={`class-${classId}`} selectedId={controller.selectedId} select={controller.select} className={classId === hunter.activeClassId ? "is-selected" : ""} aria-pressed={classId === hunter.activeClassId} onClick={() => selectHealerClass(classId)}>
|
||
<i style={{ color: healer.color }}>{healer.icon}</i><span><strong>{healer.name}</strong><small>Level {progress.level} · {progress.inventory.length} items</small></span><b>{classId === hunter.activeClassId ? "✓" : ""}</b>
|
||
</ControllerButton>;
|
||
})}</div></div>
|
||
<ControllerButton id="change-save" selectedId={controller.selectedId} select={controller.select} className="change-save" onClick={() => navigate("saves")}><span>Change save slot</span><small>Last saved {formatSaveTimestamp(hunter.updatedAt)}</small></ControllerButton>
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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);
|
||
const navigate = useFrontendStore((state) => state.navigate);
|
||
const uploadSlot = useFrontendStore((state) => state.uploadSlot);
|
||
const collectionView = useFrontendStore((state) => state.profileCollectionView);
|
||
const groupId = useFrontendStore((state) => state.selectedProfileGroupId);
|
||
const selectedStat = useFrontendStore((state) => state.selectedProfileStatId);
|
||
const setCollectionView = useFrontendStore((state) => state.selectProfileCollectionView);
|
||
const setGroupId = useFrontendStore((state) => state.selectProfileGroup);
|
||
const setSelectedStat = useFrontendStore((state) => state.selectProfileStat);
|
||
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
||
const bosses = useMemo(() => alphabeticalBosses(collections), [collections]);
|
||
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
|
||
const profileView = collectionView === "loot" ? "loot" : "stats";
|
||
const activeSection = profileSectionForStat(selectedStat);
|
||
const activeSectionDefinition = PROFILE_SECTIONS.find((section) => section.id === activeSection) ?? PROFILE_SECTIONS[0];
|
||
const selectedBoss = isBossProfileStat(selectedStat) ? bosses.find((boss) => boss.bossId === selectedStat) : undefined;
|
||
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
|
||
const [leaderboardStatus, setLeaderboardStatus] = useState("");
|
||
const [leaderboardUpdatedAt, setLeaderboardUpdatedAt] = useState<string | null>(null);
|
||
const [leaderboardOwner, setLeaderboardOwner] = useState<string | null>(null);
|
||
const [refreshingLeaderboard, setRefreshingLeaderboard] = useState(false);
|
||
const leaderboardRequestId = useRef(0);
|
||
const leaderboardRefreshActive = useRef(false);
|
||
const displaySurface = document.documentElement.dataset.displaySurface;
|
||
const rendersTopSurface = displaySurface !== "bottom";
|
||
const rendersBottomSurface = displaySurface !== "top";
|
||
useEffect(() => {
|
||
if (!isBossProfileStat(selectedStat) || bosses.some((boss) => boss.bossId === selectedStat)) return;
|
||
setSelectedStat(bosses[0]?.bossId ?? "roguelike");
|
||
}, [bosses, selectedStat, setSelectedStat]);
|
||
useEffect(() => {
|
||
if (!rendersTopSurface || !hunter || profileView !== "stats" || activeSection === "bosses") return;
|
||
const requestId = ++leaderboardRequestId.current;
|
||
leaderboardRefreshActive.current = false;
|
||
setRefreshingLeaderboard(false);
|
||
const cached = leaderboardCache.read(hunter.slotId, selectedStat, hunter.hunterName, accountId);
|
||
setLeaderboard(cached?.result ?? null);
|
||
setLeaderboardUpdatedAt(cached?.updatedAt ?? null);
|
||
setLeaderboardOwner(cached?.accountId ?? accountId);
|
||
setLeaderboardStatus(cached
|
||
? ""
|
||
: accountId
|
||
? "No cached rankings. Refresh when online."
|
||
: "Sign in once, then refresh rankings for offline viewing.");
|
||
return () => {
|
||
if (leaderboardRequestId.current === requestId) leaderboardRequestId.current += 1;
|
||
};
|
||
}, [accountId, activeSection, hunter, profileView, rendersTopSurface, selectedStat]);
|
||
const refreshLeaderboard = useCallback(async () => {
|
||
if (!rendersTopSurface || !hunter || leaderboardRefreshActive.current) return;
|
||
if (!accountId) {
|
||
setLeaderboardStatus("Sign in to refresh online rankings.");
|
||
return;
|
||
}
|
||
if (!networkAppearsOnline()) {
|
||
setLeaderboardStatus("Offline. Cached rankings remain available.");
|
||
return;
|
||
}
|
||
const requestId = ++leaderboardRequestId.current;
|
||
leaderboardRefreshActive.current = true;
|
||
setRefreshingLeaderboard(true);
|
||
if (hasPendingSaveSync(hunter.slotId)) {
|
||
setLeaderboardStatus("Publishing local records…");
|
||
const uploaded = await uploadSlot(hunter.slotId);
|
||
if (leaderboardRequestId.current !== requestId) return;
|
||
if (!uploaded) {
|
||
setLeaderboardStatus("Local records queued. Refresh when connection returns.");
|
||
leaderboardRefreshActive.current = false;
|
||
setRefreshingLeaderboard(false);
|
||
return;
|
||
}
|
||
}
|
||
setLeaderboardStatus("Refreshing online rankings…");
|
||
const request = selectedStat === "roguelike"
|
||
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
|
||
: selectedStat === "rogue-trials-endless"
|
||
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
|
||
: selectedStat === "hockey-healing"
|
||
? onlineRepository.hockeyHealingLeaderboard(hunter.slotId)
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? onlineRepository.hockeyPvpWinsLeaderboard(hunter.slotId)
|
||
: selectedStat === "hockey-pvp-boss-kills"
|
||
? onlineRepository.hockeyPvpBossKillsLeaderboard(hunter.slotId)
|
||
: selectedStat === "blockbreaker-bricks"
|
||
? onlineRepository.blockbreakerBricksLeaderboard(hunter.slotId)
|
||
: selectedStat === "blockbreaker-time"
|
||
? onlineRepository.blockbreakerTimeLeaderboard(hunter.slotId)
|
||
: selectedStat === "blockbreaker-score"
|
||
? onlineRepository.blockbreakerScoreLeaderboard(hunter.slotId)
|
||
: selectedStat === "aether-assault"
|
||
? onlineRepository.aetherAssaultLeaderboard(hunter.slotId)
|
||
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
|
||
try {
|
||
const result = await request;
|
||
const cached = leaderboardCache.write(accountId, hunter.hunterName, hunter.slotId, selectedStat, result);
|
||
if (leaderboardRequestId.current !== requestId) return;
|
||
setLeaderboard(result);
|
||
setLeaderboardUpdatedAt(cached.updatedAt);
|
||
setLeaderboardOwner(accountId);
|
||
setLeaderboardStatus("");
|
||
} catch (error) {
|
||
if (leaderboardRequestId.current !== requestId) return;
|
||
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
|
||
} finally {
|
||
if (leaderboardRequestId.current === requestId) {
|
||
leaderboardRefreshActive.current = false;
|
||
setRefreshingLeaderboard(false);
|
||
}
|
||
}
|
||
}, [accountId, hunter, rendersTopSurface, selectedStat, uploadSlot]);
|
||
const sectionMetricIds = activeSectionDefinition.statIds;
|
||
const bossGridColumns = 7;
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
...(rendersTopSurface ? [
|
||
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { right: "view-loot", down: profileView === "stats" ? "section-roguelike" : undefined } },
|
||
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
|
||
...(profileView === "stats" ? [
|
||
...PROFILE_SECTIONS.map((section, index) => ({
|
||
id: `section-${section.id}`,
|
||
run: () => setSelectedStat(defaultStatForSection(section.id, bosses)),
|
||
neighbors: {
|
||
up: index === 0 ? "view-stats" : `section-${PROFILE_SECTIONS[index - 1].id}`,
|
||
down: index === PROFILE_SECTIONS.length - 1 ? "section-roguelike" : `section-${PROFILE_SECTIONS[index + 1].id}`,
|
||
right: section.id === "bosses" ? `boss-card-${bosses[0]?.bossId}` : `metric-${section.statIds[0]}`,
|
||
},
|
||
})),
|
||
...(activeSection === "bosses" ? bosses.map((boss, index) => {
|
||
const rowStart = index % bossGridColumns === 0;
|
||
const rowEnd = index % bossGridColumns === bossGridColumns - 1 || index === bosses.length - 1;
|
||
return {
|
||
id: `boss-card-${boss.bossId}`,
|
||
run: () => setSelectedStat(boss.bossId),
|
||
neighbors: {
|
||
left: rowStart ? "section-bosses" : `boss-card-${bosses[index - 1].bossId}`,
|
||
right: rowEnd ? `boss-card-${boss.bossId}` : `boss-card-${bosses[index + 1].bossId}`,
|
||
up: index < bossGridColumns ? "view-stats" : `boss-card-${bosses[index - bossGridColumns].bossId}`,
|
||
down: bosses[index + bossGridColumns] ? `boss-card-${bosses[index + bossGridColumns].bossId}` : `boss-card-${boss.bossId}`,
|
||
},
|
||
};
|
||
}) : [
|
||
...sectionMetricIds.map((statId, index) => ({
|
||
id: `metric-${statId}`,
|
||
run: () => setSelectedStat(statId),
|
||
neighbors: {
|
||
left: index === 0 ? `section-${activeSection}` : `metric-${sectionMetricIds[index - 1]}`,
|
||
right: index === sectionMetricIds.length - 1 ? "refresh-leaderboard" : `metric-${sectionMetricIds[index + 1]}`,
|
||
up: "view-stats",
|
||
down: "refresh-leaderboard",
|
||
},
|
||
})),
|
||
{ id: "refresh-leaderboard", run: () => { void refreshLeaderboard(); }, enabled: Boolean(accountId), neighbors: { left: `metric-${selectedStat}`, up: `metric-${selectedStat}` } },
|
||
]),
|
||
] : []),
|
||
{ id: "back", run: () => navigate("home") },
|
||
] : []),
|
||
...(rendersBottomSurface && profileView === "loot" ? collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })) : []),
|
||
], [accountId, activeSection, bosses, collections, navigate, profileView, refreshLeaderboard, rendersBottomSurface, rendersTopSurface, sectionMetricIds, selectedStat, setCollectionView, setGroupId, setSelectedStat]);
|
||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||
useEffect(() => {
|
||
const hoveredSection = PROFILE_SECTIONS.find((section) => `section-${section.id}` === controller.selectedId);
|
||
if (hoveredSection) {
|
||
const nextStat = defaultStatForSection(hoveredSection.id, bosses);
|
||
if (nextStat !== selectedStat) setSelectedStat(nextStat);
|
||
return;
|
||
}
|
||
const hoveredMetric = sectionMetricIds.find((statId) => `metric-${statId}` === controller.selectedId);
|
||
if (hoveredMetric && hoveredMetric !== selectedStat) {
|
||
setSelectedStat(hoveredMetric);
|
||
return;
|
||
}
|
||
const hoveredBoss = bosses.find((boss) => `boss-card-${boss.bossId}` === controller.selectedId);
|
||
if (hoveredBoss && hoveredBoss.bossId !== selectedStat) setSelectedStat(hoveredBoss.bossId);
|
||
}, [bosses, controller.selectedId, sectionMetricIds, selectedStat, setSelectedStat]);
|
||
if (!hunter || !collection) return null;
|
||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||
const activeProgress = hunter.healers[hunter.activeClassId];
|
||
const earned = collection.drops.filter((drop) => drop.count > 0).length;
|
||
const petsOwned = bosses.filter((boss) => boss.pet.count > 0).length;
|
||
const hockeyDuration = `${Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestHockeyHealingSecondsAtBest % 60)).padStart(2, "0")}`;
|
||
const blockbreakerDuration = `${Math.floor(hunter.stats.longestBlockbreakerSeconds / 60)}:${String(Math.floor(hunter.stats.longestBlockbreakerSeconds % 60)).padStart(2, "0")}`;
|
||
const aetherDuration = `${Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest / 60)}:${String(Math.floor(hunter.stats.longestAetherAssaultSecondsAtBest % 60)).padStart(2, "0")}`;
|
||
const metrics: { id: ProfileStatId; label: string; value: string; copy: string }[] = activeSection === "roguelike"
|
||
? [{ id: "roguelike", label: "Highest round", value: hunter.stats.highestRoguelikeRound.toLocaleString(), copy: "Best run before defeat" }]
|
||
: activeSection === "rogue-trials"
|
||
? [{ id: "rogue-trials-endless", label: "Endless best", value: hunter.stats.highestRogueTrialsEndlessKills.toLocaleString(), copy: "Bosses defeated in one run" }]
|
||
: activeSection === "hockey"
|
||
? [{ id: "hockey-healing", label: "Hockey record", value: `${hunter.stats.highestHockeyHealingReturns} returns`, copy: `${hockeyDuration} longest survival` }]
|
||
: activeSection === "hockey-pvp"
|
||
? [
|
||
{ id: "hockey-pvp-wins", label: "Match record", value: `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L`, copy: "Lifetime PVP results" },
|
||
{ id: "hockey-pvp-boss-kills", label: "Boss race kills", value: hunter.stats.hockeyHealingPvpBossKills.toLocaleString(), copy: "Lifetime PVP bosses" },
|
||
]
|
||
: activeSection === "aether-assault"
|
||
? [{ id: "aether-assault", label: "Overall score", value: hunter.stats.highestAetherAssaultScore.toLocaleString(), copy: `Wave ${hunter.stats.highestAetherAssaultWaveAtBest} · ${aetherDuration}` }]
|
||
: [
|
||
{ id: "blockbreaker-score", label: "Overall score", value: hunter.stats.highestBlockbreakerScore.toLocaleString(), copy: "Highest single-run score" },
|
||
{ id: "blockbreaker-bricks", label: "Bricks broken", value: hunter.stats.highestBlockbreakerBricks.toLocaleString(), copy: "Most in one run" },
|
||
{ id: "blockbreaker-time", label: "Time survived", value: blockbreakerDuration, copy: "Longest run" },
|
||
];
|
||
const selectedStatValue = selectedStat === "roguelike"
|
||
? hunter.stats.highestRoguelikeRound
|
||
: selectedStat === "rogue-trials-endless"
|
||
? hunter.stats.highestRogueTrialsEndlessKills
|
||
: selectedStat === "hockey-healing"
|
||
? hunter.stats.highestHockeyHealingReturns
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? hunter.stats.hockeyHealingPvpWins
|
||
: selectedStat === "hockey-pvp-boss-kills"
|
||
? hunter.stats.hockeyHealingPvpBossKills
|
||
: selectedStat === "blockbreaker-bricks"
|
||
? hunter.stats.highestBlockbreakerBricks
|
||
: selectedStat === "blockbreaker-time"
|
||
? hunter.stats.longestBlockbreakerSeconds
|
||
: selectedStat === "blockbreaker-score"
|
||
? hunter.stats.highestBlockbreakerScore
|
||
: selectedStat === "aether-assault"
|
||
? hunter.stats.highestAetherAssaultScore
|
||
: isBossProfileStat(selectedStat)
|
||
? hunter.stats.bossKills[selectedStat] ?? 0
|
||
: 0;
|
||
const selectedStatLabel = selectedStat === "roguelike"
|
||
? "Roguelike rounds"
|
||
: selectedStat === "rogue-trials-endless"
|
||
? "Rogue Trials endless kills"
|
||
: selectedStat === "hockey-healing"
|
||
? "Hockey Healing returns"
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? "Healing Hockey PVP wins"
|
||
: selectedStat === "hockey-pvp-boss-kills"
|
||
? "Healing Hockey PVP boss kills"
|
||
: selectedStat === "blockbreaker-bricks"
|
||
? "Blockbreaker bricks broken"
|
||
: selectedStat === "blockbreaker-time"
|
||
? "Blockbreaker survival time"
|
||
: selectedStat === "blockbreaker-score"
|
||
? "Blockbreaker overall score"
|
||
: selectedStat === "aether-assault"
|
||
? "Aether Assault score"
|
||
: isBossProfileStat(selectedStat)
|
||
? BOSS_DEFINITIONS[selectedStat].name
|
||
: "Hunter record";
|
||
const leaderboardValue = (value: number, secondaryValue?: number) => selectedStat === "hockey-healing"
|
||
? `${value} · ${Math.floor((secondaryValue ?? 0) / 60)}:${String(Math.floor((secondaryValue ?? 0) % 60)).padStart(2, "0")}`
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? `${value}W · ${secondaryValue ?? 0}L`
|
||
: selectedStat === "blockbreaker-time"
|
||
? `${Math.floor(value / 60)}:${String(Math.floor(value % 60)).padStart(2, "0")}`
|
||
: selectedStat === "aether-assault"
|
||
? `${value.toLocaleString()} · Wave ${secondaryValue ?? 0}`
|
||
: value.toLocaleString();
|
||
const selectedStatSummary = selectedStat === "hockey-healing"
|
||
? `${selectedStatValue} returns · ${hockeyDuration}`
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? `${hunter.stats.hockeyHealingPvpWins}W · ${hunter.stats.hockeyHealingPvpLosses}L`
|
||
: selectedStat === "blockbreaker-time"
|
||
? `${Math.floor(selectedStatValue / 60)}:${String(Math.floor(selectedStatValue % 60)).padStart(2, "0")} survived`
|
||
: selectedStat === "blockbreaker-score"
|
||
? `${selectedStatValue.toLocaleString()} points`
|
||
: selectedStat === "aether-assault"
|
||
? `${selectedStatValue.toLocaleString()} points · Wave ${hunter.stats.highestAetherAssaultWaveAtBest}`
|
||
: selectedStat === "blockbreaker-bricks"
|
||
? `${selectedStatValue.toLocaleString()} bricks`
|
||
: `${selectedStatValue.toLocaleString()} ${selectedStat === "roguelike" ? "round" : "kills"}`;
|
||
const selectedSecondaryValue = selectedStat === "hockey-healing"
|
||
? hunter.stats.longestHockeyHealingSecondsAtBest
|
||
: selectedStat === "hockey-pvp-wins"
|
||
? hunter.stats.hockeyHealingPvpLosses
|
||
: selectedStat === "aether-assault"
|
||
? hunter.stats.highestAetherAssaultWaveAtBest
|
||
: undefined;
|
||
const leaderboardMeta = leaderboardStatus
|
||
|| (leaderboardUpdatedAt ? `Cached ${formatSaveTimestamp(leaderboardUpdatedAt)}` : "Offline cache empty");
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
|
||
<header className="front-screen-header profile-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Records</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Profile view">
|
||
<ControllerButton id="view-stats" selectedId={controller.selectedId} select={controller.select} className={profileView === "stats" ? "is-selected" : ""} role="tab" aria-selected={profileView === "stats"} onClick={() => setCollectionView("stats")}>Records</ControllerButton>
|
||
<ControllerButton id="view-loot" selectedId={controller.selectedId} select={controller.select} className={profileView === "loot" ? "is-selected" : ""} role="tab" aria-selected={profileView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</ControllerButton>
|
||
</div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
|
||
{profileView === "loot" ? <>
|
||
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
|
||
<div className="collection-grid">
|
||
{collection.drops.map((drop) => (
|
||
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
|
||
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
|
||
<small>{drop.rarity}</small><strong>{drop.name}</strong>
|
||
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
|
||
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="collection-note"><i>♛</i><span><strong>Boss pets live in the Bosses record grid.</strong><small>Every boss, kill count, and pet status stays together.</small></span></div>
|
||
</> : <>
|
||
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Controller-first collection log</small><h2>Hunter Records</h2></span><b>{petsOwned} / {bosses.length} boss pets found</b></div>
|
||
<div className={`boss-stats-layout ${activeSection === "bosses" ? "is-boss-index" : ""}`}>
|
||
<section className="boss-stat-selector" aria-label="Hunter record section">
|
||
{PROFILE_SECTIONS.map((section) => <ControllerButton key={section.id} id={`section-${section.id}`} selectedId={controller.selectedId} select={controller.select} className={activeSection === section.id ? "is-selected" : ""} onClick={() => setSelectedStat(defaultStatForSection(section.id, bosses))}><i>{section.icon}</i><span><strong>{section.label}</strong><small>{section.copy}</small></span><b>›</b></ControllerButton>)}
|
||
</section>
|
||
{activeSection === "bosses" ? <section className="boss-index-panel" aria-label="All bosses alphabetically">
|
||
<header><span><small>Boss pets · A–Z</small><strong>Every guardian</strong></span><b>{hunter.stats.totalBossKills.toLocaleString()} total kills</b></header>
|
||
<div className="boss-pet-grid" role="grid" aria-label="Boss pet kill records">
|
||
{bosses.map((boss) => {
|
||
const owned = boss.pet.count > 0;
|
||
return <ControllerButton key={boss.bossId} id={`boss-card-${boss.bossId}`} selectedId={controller.selectedId} select={controller.select} className={`boss-pet-card ${selectedStat === boss.bossId ? "is-selected" : ""} ${owned ? "is-owned" : "is-unowned"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties} role="gridcell" aria-label={`${boss.bossName}: ${boss.kills} kills; boss pet ${owned ? "owned" : "not owned"}`} aria-pressed={selectedStat === boss.bossId} onClick={() => setSelectedStat(boss.bossId)}>
|
||
<span className="boss-pet-icon" aria-hidden="true">{boss.pet.icon}</span>
|
||
<span className="boss-kill-badge"><small>Kills</small><b>{boss.kills.toLocaleString()}</b></span>
|
||
<strong>{boss.bossName}</strong>
|
||
<small>{owned ? `Pet owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Pet not found"}</small>
|
||
</ControllerButton>;
|
||
})}
|
||
</div>
|
||
</section> : <section className="profile-records-panel" aria-label={`${activeSectionDefinition.label} records`}>
|
||
<header><span><small>All {activeSectionDefinition.label} stats</small><strong>{activeSectionDefinition.copy}</strong></span><b>{metrics.length} record{metrics.length === 1 ? "" : "s"}</b></header>
|
||
<div className={`profile-metric-grid metric-count-${metrics.length}`}>
|
||
{metrics.map((metric) => <ControllerButton key={metric.id} id={`metric-${metric.id}`} selectedId={controller.selectedId} select={controller.select} className={selectedStat === metric.id ? "is-selected" : ""} aria-pressed={selectedStat === metric.id} onClick={() => setSelectedStat(metric.id)}><small>{metric.label}</small><strong>{metric.value}</strong><span>{metric.copy}</span></ControllerButton>)}
|
||
</div>
|
||
<section className="leaderboard-panel profile-leaderboard" aria-label="Overall leaderboard">
|
||
<header><span><small role="status" aria-live="polite">{leaderboardMeta}</small><strong>{selectedStatLabel}</strong></span><div className="leaderboard-header-actions"><b>{selectedStatSummary}</b><ControllerButton id="refresh-leaderboard" selectedId={controller.selectedId} select={controller.select} className="leaderboard-refresh" disabled={!accountId || refreshingLeaderboard} onClick={() => { void refreshLeaderboard(); }}>{refreshingLeaderboard ? "Refreshing…" : "Refresh online"}</ControllerButton></div></header>
|
||
{leaderboard ? <div className="leaderboard-rows">
|
||
{leaderboard.top.length ? leaderboard.top.slice(0, 4).map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === leaderboardOwner && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{leaderboardValue(entry.value, entry.secondaryValue)}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
|
||
</div> : <div className="leaderboard-status">{leaderboardStatus || "No cached rankings."}</div>}
|
||
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{leaderboardOwner ?? "Offline hunter"}</small></span><em>{leaderboardValue(selectedStatValue, selectedSecondaryValue)}</em></div>
|
||
</section>
|
||
</section>}
|
||
</div>
|
||
<div className="collection-note trophy-note"><i>{activeSection === "bosses" ? "♛" : "◆"}</i><span><strong>{activeSection === "bosses" ? "Move through the grid to inspect a boss below." : "Every stat in this section stays visible together."}</strong><small>{activeSection === "bosses" ? "The lower screen shows kills, pet ownership, and collection details." : "Select a record card to change the online leaderboard."}</small></span></div>
|
||
</>}
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className={`profile-context ${activeSection === "bosses" && selectedBoss ? "is-boss-detail" : ""}`} bottom ariaLabel="Hunter statistics and selected boss details">
|
||
{profileView === "stats" && activeSection === "bosses" && selectedBoss ? <>
|
||
<header className="context-header"><span>Boss record · {selectedBoss.bossName}</span><b>{selectedBoss.pet.count > 0 ? "PET OWNED" : "PET NOT FOUND"}</b></header>
|
||
<div className="selected-boss-dossier" style={{ "--boss-accent": BOSS_DEFINITIONS[selectedBoss.bossId].accent } as React.CSSProperties}>
|
||
<div className={`selected-boss-pet ${selectedBoss.pet.count > 0 ? "is-owned" : "is-unowned"}`}><span>{selectedBoss.pet.icon}</span><small>Boss pet icon</small></div>
|
||
<div className="selected-boss-copy"><small>{BOSS_DEFINITIONS[selectedBoss.bossId].title}</small><h2>{selectedBoss.bossName}</h2><p>{BOSS_DEFINITIONS[selectedBoss.bossId].summary}</p></div>
|
||
</div>
|
||
<div className="selected-boss-stats">
|
||
<span><small>Times killed</small><strong>{selectedBoss.kills.toLocaleString()}</strong></span>
|
||
<span><small>Boss pet</small><strong>{selectedBoss.pet.count > 0 ? "Owned" : "Not found"}</strong></span>
|
||
<span><small>Pet copies</small><strong>{selectedBoss.pet.count.toLocaleString()}</strong></span>
|
||
<span><small>Drop chance</small><strong>{selectedBoss.pet.chance}</strong></span>
|
||
</div>
|
||
<div className="selected-boss-mechanics"><span>Encounter read</span><strong>{BOSS_DEFINITIONS[selectedBoss.bossId].briefing}</strong><small>Move on upper-screen grid to inspect another boss.</small></div>
|
||
</> : <>
|
||
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
|
||
<div className="profile-stats">
|
||
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
|
||
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
|
||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
||
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
|
||
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
|
||
<span><small>Hockey PVP record</small><strong>{hunter.stats.hockeyHealingPvpWins}W · {hunter.stats.hockeyHealingPvpLosses}L</strong></span>
|
||
<span><small>Boss pets found</small><strong>{petsOwned}/{bosses.length}</strong></span>
|
||
</div>
|
||
{profileView === "loot" && <div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
||
<ControllerButton key={group.groupId} id={`group-${group.groupId}`} selectedId={controller.selectedId} select={controller.select} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
||
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
|
||
</ControllerButton>
|
||
))}</div>}
|
||
</>}
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function GearScreen() {
|
||
const hunter = useActiveHunter();
|
||
const navigate = useFrontendStore((state) => state.navigate);
|
||
const notice = useFrontendStore((state) => state.notice);
|
||
const selectedOwnerId = useFrontendStore((state) => state.selectedGearOwnerId);
|
||
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
|
||
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
|
||
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
|
||
const selectedPassiveAbilityId = useFrontendStore((state) => state.selectedPassiveAbilityId);
|
||
const selectedPassiveInfusionId = useFrontendStore((state) => state.selectedPassiveInfusionId);
|
||
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
||
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
||
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
||
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
||
const selectPassiveAbility = useFrontendStore((state) => state.selectPassiveAbility);
|
||
const selectPassiveInfusion = useFrontendStore((state) => state.selectPassiveInfusion);
|
||
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
|
||
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
|
||
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
|
||
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
|
||
const upgradeReadiness = useMemo(() => {
|
||
const owners = new Set<GearOwnerId>();
|
||
const slots = new Set<string>();
|
||
if (!hunter) return { owners, slots };
|
||
|
||
for (const ownerId of GEAR_OWNER_ORDER) {
|
||
for (const slotId of GEAR_SLOT_ORDER) {
|
||
if (!canUpgradeGearSlot(hunter.gearProgress, hunter.materials, ownerId, slotId)) continue;
|
||
owners.add(ownerId);
|
||
slots.add(`${ownerId}:${slotId}`);
|
||
}
|
||
}
|
||
return { owners, slots };
|
||
}, [hunter]);
|
||
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
|
||
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
|
||
const canUpgrade = upgradeReadiness.slots.has(`${selectedOwnerId}:${selectedSlotId}`);
|
||
const infusionChoices = infusionsForOwner(selectedOwnerId);
|
||
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
|
||
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
|
||
const activeUnlocked = Boolean(hunter && activeInfusionUnlocked(hunter.gearProgress[selectedOwnerId]));
|
||
const anchorUnlocked = Boolean(slot && slot.level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
|
||
const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id;
|
||
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
|
||
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
|
||
const healerOwner = isHealerClassId(selectedOwnerId);
|
||
const passiveHealerClassId = isHealerClassId(selectedOwnerId) ? selectedOwnerId : "priest";
|
||
const passiveChoices = PASSIVE_INFUSIONS.filter((passive) => passive.abilitySlotId === selectedPassiveAbilityId);
|
||
const selectedPassive = RUN_BUFFS[selectedPassiveInfusionId];
|
||
const healerAbilities = HEALER_CLASSES[passiveHealerClassId].abilities;
|
||
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
|
||
id: `owner-${ownerId}`,
|
||
run: () => selectOwner(ownerId),
|
||
neighbors: {
|
||
up: index === 0 ? "back" : `owner-${GEAR_OWNER_ORDER[index - 1]}`,
|
||
down: index === GEAR_OWNER_ORDER.length - 1 ? previewEntryId : `owner-${GEAR_OWNER_ORDER[index + 1]}`,
|
||
right: `slot-${selectedSlotId}`,
|
||
},
|
||
})),
|
||
...GEAR_SLOT_ORDER.map((slotId, index) => ({
|
||
id: `slot-${slotId}`,
|
||
run: () => selectSlot(slotId),
|
||
neighbors: {
|
||
up: index === 0 ? "workshop-upgrade" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
|
||
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
|
||
left: `owner-${selectedOwnerId}`,
|
||
right: previewEntryId,
|
||
},
|
||
})),
|
||
{ id: "workshop-upgrade", run: () => selectWorkshopMode("upgrade"), neighbors: { right: "workshop-infusion", down: "slot-weapon" } },
|
||
{ id: "workshop-infusion", run: () => selectWorkshopMode("infusion"), neighbors: { left: "workshop-upgrade", down: `infusion-${infusionChoices[0].id}` } },
|
||
...infusionChoices.map((infusion, index) => ({
|
||
id: `infusion-${infusion.id}`,
|
||
run: () => selectInfusion(infusion.id),
|
||
neighbors: {
|
||
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
|
||
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
|
||
left: `slot-${selectedSlotId}`,
|
||
right: index === infusionChoices.length - 1 ? "install-infusion" : undefined,
|
||
},
|
||
})),
|
||
...(healerOwner ? ABILITY_ORDER.map((abilityId, index) => ({
|
||
id: `passive-ability-${abilityId}`,
|
||
run: () => selectPassiveAbility(abilityId),
|
||
neighbors: {
|
||
up: index < 3 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-ability-${ABILITY_ORDER[index - 3]}`,
|
||
down: index < 3
|
||
? `passive-ability-${ABILITY_ORDER[index + 3]}`
|
||
: `passive-${passiveChoices[Math.min(index - 3, passiveChoices.length - 1)].id}`,
|
||
left: index % 3 > 0 ? `passive-ability-${ABILITY_ORDER[index - 1]}` : `slot-${selectedSlotId}`,
|
||
right: index % 3 < 2 ? `passive-ability-${ABILITY_ORDER[index + 1]}` : undefined,
|
||
},
|
||
})) : []),
|
||
...(healerOwner ? passiveChoices.map((passive, index) => ({
|
||
id: `passive-${passive.id}`,
|
||
run: () => {
|
||
selectPassiveInfusion(passive.id);
|
||
installPassive(passive.id);
|
||
},
|
||
enabled: passiveUnlocked,
|
||
neighbors: {
|
||
up: index === 0 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index - 1].id}`,
|
||
down: index === passiveChoices.length - 1 ? `passive-ability-${selectedPassiveAbilityId}` : `passive-${passiveChoices[index + 1].id}`,
|
||
left: `slot-${selectedSlotId}`,
|
||
},
|
||
})) : []),
|
||
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
|
||
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: `infusion-${infusionChoices[infusionChoices.length - 1].id}`, down: healerOwner ? `passive-ability-${selectedPassiveAbilityId}` : undefined } },
|
||
{ id: "back", run: () => navigate("home"), neighbors: { left: "workshop-infusion", down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
||
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveChoices, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectPassiveAbility, selectPassiveInfusion, selectSlot, selectWorkshopMode, selectedOwnerId, selectedPassiveAbilityId, selectedSlotId, upgrade]);
|
||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||
const passiveContext = workshopMode === "infusion" && healerOwner && controller.selectedId.startsWith("passive-");
|
||
if (!hunter || !slot) return null;
|
||
const currentBonus = gearBonusText(recipe.statId, slot.level);
|
||
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
||
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><ControllerButton id="workshop-upgrade" selectedId={controller.selectedId} select={controller.select} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</ControllerButton><ControllerButton id="workshop-infusion" selectedId={controller.selectedId} select={controller.select} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</ControllerButton></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
|
||
<div className="gear-workshop-layout">
|
||
<section className="gear-owner-list" aria-label="Party gear owners">
|
||
{GEAR_OWNER_ORDER.map((ownerId) => {
|
||
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
|
||
const upgradeReady = upgradeReadiness.owners.has(ownerId);
|
||
return <ControllerButton key={ownerId} id={`owner-${ownerId}`} selectedId={controller.selectedId} select={controller.select} aria-label={`${GEAR_OWNER_LABELS[ownerId]}${upgradeReady ? ", upgrade available" : ""}`} className={`${ownerId === selectedOwnerId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></ControllerButton>;
|
||
})}
|
||
</section>
|
||
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
|
||
{GEAR_SLOT_ORDER.map((slotId) => {
|
||
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
|
||
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
|
||
const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`);
|
||
return <ControllerButton key={slotId} id={`slot-${slotId}`} selectedId={controller.selectedId} select={controller.select} aria-label={`${GEAR_SLOT_LABELS[slotId]} +${progress.level}${upgradeReady ? ", upgrade available" : ""}`} className={`${slotId === selectedSlotId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></ControllerButton>;
|
||
})}
|
||
</section>
|
||
{workshopMode === "upgrade" ? <article className="gear-preview">
|
||
<span>Selected upgrade</span>
|
||
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
|
||
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
|
||
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i>→</i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
|
||
</article> : <article className="gear-preview gear-infusion-preview">
|
||
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
|
||
<h2>{selectedInfusion.icon} {selectedInfusion.name}</h2>
|
||
<p>{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.</p>
|
||
<div className="gear-infusion-options">
|
||
{infusionChoices.map((infusion) => <ControllerButton key={infusion.id} id={`infusion-${infusion.id}`} selectedId={controller.selectedId} select={controller.select} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></ControllerButton>)}
|
||
</div>
|
||
{healerOwner && <div className="gear-passive-options">
|
||
<span>Passive blessing · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>
|
||
<div className="gear-passive-ability-filter">
|
||
{ABILITY_ORDER.map((abilityId) => <ControllerButton key={abilityId} id={`passive-ability-${abilityId}`} selectedId={controller.selectedId} select={controller.select} className={selectedPassiveAbilityId === abilityId ? "is-selected" : ""} onClick={() => selectPassiveAbility(abilityId)}>{healerAbilities[abilityId].shortName}</ControllerButton>)}
|
||
</div>
|
||
<div className="gear-passive-choice-list">
|
||
{passiveChoices.map((passive) => <ControllerButton
|
||
key={passive.id}
|
||
id={`passive-${passive.id}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
disabled={!passiveUnlocked}
|
||
className={`${selectedPassiveInfusionId === passive.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""}`}
|
||
onPointerEnter={() => selectPassiveInfusion(passive.id)}
|
||
onClick={() => { selectPassiveInfusion(passive.id); installPassive(passive.id); }}
|
||
><i>{passive.icon}</i><span><strong>{healerAbilities[passive.abilitySlotId].shortName}: {passive.name}</strong><small>{formatRunBuffEffect(passive.id, 1, healerAbilities[passive.abilitySlotId].shortName)}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></ControllerButton>)}
|
||
</div>
|
||
</div>}
|
||
</article>}
|
||
</div>
|
||
<ControllerLegend back />
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
||
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : passiveContext ? `${healerAbilities[selectedPassive.abilitySlotId].name}: ${selectedPassive.name}` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
|
||
<div className="gear-costs">
|
||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : passiveContext ? "Passive blessing · Rank 1" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||
{passiveContext ? <article className={passiveUnlocked ? "is-met" : "is-missing"}><i>{passiveUnlocked ? "✓" : "×"}</i><span><strong>{formatRunBuffEffect(selectedPassive.id, 1, healerAbilities[selectedPassive.abilitySlotId].shortName)}</strong><small>{selectedPassive.detail}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "EQUIPPED" : "RANK 1"}</b></article> : (workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
||
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
|
||
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
||
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
||
</div>
|
||
{workshopMode === "upgrade" ? <ControllerButton id="upgrade" selectedId={controller.selectedId} select={controller.select} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></ControllerButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : `${DEFAULT_CONTROLLER_GLYPHS.confirm} · Equip selected passive`}</span><small>Applies at rank 1 next encounter.</small></div> : <ControllerButton id="install-infusion" selectedId={controller.selectedId} select={controller.select} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></ControllerButton>}
|
||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const APPEARANCE_PREVIEW_ANIMATIONS = [
|
||
{ id: "idle", label: "Idle" },
|
||
{ id: "walk", label: "Walk" },
|
||
{ id: "cast", label: "Cast" },
|
||
] as const;
|
||
|
||
function appearanceControlId(slotId: AppearanceSlotId, direction: "previous" | "next") {
|
||
return `appearance-${slotId}-${direction}`;
|
||
}
|
||
|
||
function AppearanceScreen() {
|
||
const hunter = useActiveHunter();
|
||
const classId = useFrontendStore((state) => state.appearanceClassId);
|
||
const drafts = useFrontendStore((state) => state.appearanceDrafts);
|
||
const previewMode = useFrontendStore((state) => state.previewMode);
|
||
const previewAnimation = useFrontendStore((state) => state.previewAnimation);
|
||
const notice = useFrontendStore((state) => state.notice);
|
||
const selectAppearanceClass = useFrontendStore((state) => state.selectAppearanceClass);
|
||
const updateAppearanceDraft = useFrontendStore((state) => state.updateAppearanceDraft);
|
||
const resetAppearanceDraft = useFrontendStore((state) => state.resetAppearanceDraft);
|
||
const saveAppearanceDraft = useFrontendStore((state) => state.saveAppearanceDraft);
|
||
const closeAppearanceLab = useFrontendStore((state) => state.closeAppearanceLab);
|
||
const setAppearancePreviewMode = useFrontendStore((state) => state.setAppearancePreviewMode);
|
||
const setAppearancePreviewAnimation = useFrontendStore((state) => state.setAppearancePreviewAnimation);
|
||
const draft = drafts[classId] ?? createDefaultHealerAppearance(classId);
|
||
const saved = hunter?.healers[classId].appearance ?? createDefaultHealerAppearance(classId);
|
||
const dirtyClassIds = HEALER_CLASS_ORDER.filter((candidate) => !appearancesMatch(
|
||
drafts[candidate] ?? createDefaultHealerAppearance(candidate),
|
||
hunter?.healers[candidate].appearance ?? createDefaultHealerAppearance(candidate),
|
||
));
|
||
const currentDirty = !appearancesMatch(draft, saved);
|
||
const dirtyCount = dirtyClassIds.length;
|
||
const [discardArmed, setDiscardArmed] = useState(false);
|
||
const effectivePreviewMode = CHARACTER_MODEL_MODE === "legacy" ? "legacy" : previewMode;
|
||
const changeSlot = useCallback((slotId: AppearanceSlotId, direction: -1 | 1) => {
|
||
updateAppearanceDraft(cycleAppearanceSlot(draft, slotId, direction));
|
||
}, [draft, updateAppearanceDraft]);
|
||
const requestClose = useCallback(() => {
|
||
if (dirtyCount > 0 && !discardArmed) {
|
||
setDiscardArmed(true);
|
||
return;
|
||
}
|
||
closeAppearanceLab();
|
||
}, [closeAppearanceLab, dirtyCount, discardArmed]);
|
||
useEffect(() => setDiscardArmed(false), [classId, drafts, previewAnimation, previewMode]);
|
||
const actions = useMemo<MenuAction[]>(() => {
|
||
const classActions = HEALER_CLASS_ORDER.map((candidate, index) => ({
|
||
id: `appearance-class-${candidate}`,
|
||
run: () => selectAppearanceClass(candidate),
|
||
neighbors: {
|
||
left: `appearance-class-${HEALER_CLASS_ORDER[(index - 1 + HEALER_CLASS_ORDER.length) % HEALER_CLASS_ORDER.length]}`,
|
||
right: `appearance-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
|
||
down: "appearance-animation-idle",
|
||
},
|
||
}));
|
||
const animationActions = APPEARANCE_PREVIEW_ANIMATIONS.map((animation, index) => ({
|
||
id: `appearance-animation-${animation.id}`,
|
||
run: () => setAppearancePreviewAnimation(animation.id),
|
||
neighbors: {
|
||
left: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index - 1 + APPEARANCE_PREVIEW_ANIMATIONS.length) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`,
|
||
right: `appearance-animation-${APPEARANCE_PREVIEW_ANIMATIONS[(index + 1) % APPEARANCE_PREVIEW_ANIMATIONS.length].id}`,
|
||
up: `appearance-class-${classId}`,
|
||
down: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[0].id, index === 0 ? "previous" : "next"),
|
||
},
|
||
}));
|
||
const slotActions = APPEARANCE_SLOT_DEFINITIONS.flatMap((slot, index) => {
|
||
const previousRow = index === 0 ? "appearance-animation-idle" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "previous");
|
||
const nextRow = index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-compare" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "previous");
|
||
const previous = appearanceControlId(slot.id, "previous");
|
||
const next = appearanceControlId(slot.id, "next");
|
||
const enabled = appearanceSlotEnabled(draft, slot.id);
|
||
return [
|
||
{ id: previous, run: () => changeSlot(slot.id, -1), enabled, neighbors: { left: next, right: next, up: previousRow, down: nextRow } },
|
||
{ id: next, run: () => changeSlot(slot.id, 1), enabled, neighbors: { left: previous, right: previous, up: index === 0 ? "appearance-animation-cast" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index - 1].id, "next"), down: index === APPEARANCE_SLOT_DEFINITIONS.length - 1 ? "appearance-save" : appearanceControlId(APPEARANCE_SLOT_DEFINITIONS[index + 1].id, "next") } },
|
||
];
|
||
});
|
||
return [
|
||
...classActions,
|
||
...animationActions,
|
||
...slotActions,
|
||
{ id: "appearance-compare", run: () => setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular"), enabled: CHARACTER_MODEL_MODE !== "legacy", neighbors: { left: "appearance-close", right: "appearance-reset", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } },
|
||
{ id: "appearance-reset", run: resetAppearanceDraft, neighbors: { left: "appearance-compare", right: "appearance-save", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "previous") } },
|
||
{ id: "appearance-save", run: () => { saveAppearanceDraft(); }, neighbors: { left: "appearance-reset", right: "appearance-close", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } },
|
||
{ id: "appearance-close", run: requestClose, neighbors: { left: "appearance-save", right: "appearance-compare", up: appearanceControlId(APPEARANCE_SLOT_DEFINITIONS.at(-1)!.id, "next") } },
|
||
];
|
||
}, [changeSlot, classId, draft, previewMode, requestClose, resetAppearanceDraft, saveAppearanceDraft, selectAppearanceClass, setAppearancePreviewAnimation, setAppearancePreviewMode]);
|
||
const controller = useMenuController(actions, { initialId: `appearance-class-${classId}`, onBack: requestClose });
|
||
if (!hunter) return null;
|
||
const healer = HEALER_CLASSES[classId];
|
||
const legacyActive = effectivePreviewMode === "legacy";
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className="appearance-surface" ariaLabel="Healer appearance preview">
|
||
<header className="front-screen-header appearance-header">
|
||
<BrandMark compact />
|
||
<div><span>Character workshop</span><h1>Appearance Lab</h1></div>
|
||
<ControllerButton id="appearance-close" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={requestClose}>{DEFAULT_CONTROLLER_GLYPHS.back} · {discardArmed ? "Confirm discard" : dirtyCount > 0 ? `Cancel (${dirtyCount} unsaved)` : "Close"}</ControllerButton>
|
||
</header>
|
||
<div className="appearance-preview-stage">
|
||
<Suspense fallback={<div className="appearance-preview-loading"><span>✦</span><strong>Assembling shared rig</strong></div>}>
|
||
<HealerAppearancePreview
|
||
classId={classId}
|
||
appearance={draft}
|
||
animation={previewAnimation}
|
||
modelMode={effectivePreviewMode}
|
||
/>
|
||
</Suspense>
|
||
<div className={`appearance-preview-mode ${legacyActive ? "is-legacy" : ""}`}>
|
||
<span>{legacyActive ? "LEGACY WHOLE MODEL" : "MODULAR LIVE PREVIEW"}</span>
|
||
<small>{legacyActive ? "Saved parts preserved but intentionally ignored" : `${previewAnimation} animation · shared Rig_Medium`}</small>
|
||
</div>
|
||
<div className="appearance-preview-name">
|
||
<small>{healer.specialization}</small>
|
||
<strong>{healer.name}</strong>
|
||
<span>{currentDirty ? "UNSAVED CHANGES" : "SAVED LOOK"}</span>
|
||
</div>
|
||
</div>
|
||
<footer className="appearance-top-hint">
|
||
<span><b>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</b> Open lower-screen controls</span>
|
||
<span>Every choice previews on same animation skeleton</span>
|
||
</footer>
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="appearance-context" bottom ariaLabel="Appearance controls">
|
||
<header className="context-header"><span>Appearance controls</span><b>{dirtyCount > 0 ? `${dirtyCount} UNSAVED` : "SAVED LOCALLY"}</b></header>
|
||
<div className="appearance-class-tabs">
|
||
{HEALER_CLASS_ORDER.map((candidate) => {
|
||
const candidateHealer = HEALER_CLASSES[candidate];
|
||
return <ControllerButton
|
||
key={candidate}
|
||
id={`appearance-class-${candidate}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
className={candidate === classId ? "is-selected" : ""}
|
||
aria-pressed={candidate === classId}
|
||
onClick={() => selectAppearanceClass(candidate)}
|
||
><i style={{ color: candidateHealer.color }}>{candidateHealer.icon}</i><span>{candidateHealer.name}</span></ControllerButton>;
|
||
})}
|
||
</div>
|
||
<div className="appearance-animation-tabs" aria-label="Preview animation">
|
||
{APPEARANCE_PREVIEW_ANIMATIONS.map((animation) => <ControllerButton
|
||
key={animation.id}
|
||
id={`appearance-animation-${animation.id}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
className={previewAnimation === animation.id ? "is-selected" : ""}
|
||
aria-pressed={previewAnimation === animation.id}
|
||
onClick={() => setAppearancePreviewAnimation(animation.id)}
|
||
>{animation.label}</ControllerButton>)}
|
||
</div>
|
||
<div className="appearance-slot-list">
|
||
{APPEARANCE_SLOT_DEFINITIONS.map((slot) => {
|
||
const active = controller.selectedId.startsWith(`appearance-${slot.id}-`);
|
||
const enabled = appearanceSlotEnabled(draft, slot.id);
|
||
return <div className={`appearance-slot-row ${active ? "is-controller-active" : ""} ${enabled ? "" : "is-disabled"}`} key={slot.id}>
|
||
<span><strong>{slot.label}</strong><small>{slot.assetNote}</small></span>
|
||
<ControllerButton id={appearanceControlId(slot.id, "previous")} selectedId={controller.selectedId} select={controller.select} disabled={!enabled} aria-label={`Previous ${slot.label}`} onClick={() => changeSlot(slot.id, -1)}>‹</ControllerButton>
|
||
<b>{appearanceSlotLabel(draft, slot.id)}</b>
|
||
<ControllerButton id={appearanceControlId(slot.id, "next")} selectedId={controller.selectedId} select={controller.select} disabled={!enabled} aria-label={`Next ${slot.label}`} onClick={() => changeSlot(slot.id, 1)}>›</ControllerButton>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
<div className="appearance-actions">
|
||
<ControllerButton id="appearance-compare" selectedId={controller.selectedId} select={controller.select} disabled={CHARACTER_MODEL_MODE === "legacy"} onClick={() => setAppearancePreviewMode(previewMode === "modular" ? "legacy" : "modular")}><strong>{legacyActive ? "Show custom" : "Compare legacy"}</strong><small>{CHARACTER_MODEL_MODE === "legacy" ? "Rollout locked" : "No save change"}</small></ControllerButton>
|
||
<ControllerButton id="appearance-reset" selectedId={controller.selectedId} select={controller.select} onClick={resetAppearanceDraft}><strong>Reset</strong><small>Class default</small></ControllerButton>
|
||
<ControllerButton id="appearance-save" selectedId={controller.selectedId} select={controller.select} className="is-primary" onClick={() => { saveAppearanceDraft(); }}><strong>Save look</strong><small>Current healer</small></ControllerButton>
|
||
<ControllerButton id="appearance-close" selectedId={controller.selectedId} select={controller.select} onClick={requestClose}><strong>{discardArmed ? "Confirm" : "Cancel"}</strong><small>{discardArmed ? "Press again" : dirtyCount > 0 ? `Discard ${dirtyCount}` : "Close lab"}</small></ControllerButton>
|
||
</div>
|
||
<div className={`appearance-save-state ${dirtyCount > 0 ? "is-dirty" : ""}`}>{discardArmed ? `Discard ${dirtyCount} unsaved healer ${dirtyCount === 1 ? "look" : "looks"}? Press Cancel or Back again.` : CHARACTER_MODEL_MODE === "legacy" ? "Legacy rollout active. Modular choices remain saved for later." : currentDirty ? "Preview changed. Save look to use it in gameplay." : dirtyCount > 0 ? `${dirtyCount} other healer ${dirtyCount === 1 ? "look is" : "looks are"} still unsaved. Switch classes to save them.` : notice || "Saved look will load in the next encounter."}</div>
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SettingToggle({ id, label, copy, value, selectedId, select, onClick }: { id: string; label: string; copy: string; value: boolean; selectedId: string; select: (id: string) => void; onClick: () => void }) {
|
||
return <ControllerButton id={id} selectedId={selectedId} select={select} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></ControllerButton>;
|
||
}
|
||
|
||
function SettingsScreen() {
|
||
const settings = useFrontendStore((state) => state.settings);
|
||
const updateSetting = useFrontendStore((state) => state.updateSetting);
|
||
const navigate = useFrontendStore((state) => state.navigate);
|
||
const notice = useFrontendStore((state) => state.notice);
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
{ id: "volume-down", run: () => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10)) },
|
||
{ id: "volume-up", run: () => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10)) },
|
||
{ id: "motion", run: () => updateSetting("reducedMotion", !settings.reducedMotion) },
|
||
{ id: "numbers", run: () => updateSetting("damageNumbers", !settings.damageNumbers) },
|
||
{ id: "text", run: () => updateSetting("largeText", !settings.largeText) },
|
||
{ id: "back", run: () => navigate("home") },
|
||
], [navigate, settings, updateSetting]);
|
||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className="settings-surface" ariaLabel="Settings">
|
||
<header className="front-screen-header"><BrandMark compact /><div><span>Field configuration</span><h1>Settings</h1></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
|
||
<div className="settings-layout">
|
||
<section><span className="settings-section-title">Audio</span><div className="volume-setting"><span><strong>Master volume</strong><small>All music, effects, and voice</small></span><div><ControllerButton id="volume-down" selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</ControllerButton><b>{settings.masterVolume}%</b><ControllerButton id="volume-up" selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</ControllerButton></div><i><em style={{ width: `${settings.masterVolume}%` }} /></i></div></section>
|
||
<section><span className="settings-section-title">Display & accessibility</span><SettingToggle id="motion" label="Reduced motion" copy="Limit non-essential UI movement" value={settings.reducedMotion} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} selectedId={controller.selectedId} select={controller.select} onClick={() => updateSetting("largeText", !settings.largeText)} /></section>
|
||
</div>
|
||
<div className="settings-save-state">{notice || "Settings write to offline storage immediately."}</div>
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="controls-context" bottom ariaLabel="Controller mapping">
|
||
<header className="context-header"><span>Controller</span><b>BUILT-IN THOR PAD</b></header>
|
||
<div className="controller-map">
|
||
<div className="pad-diagram"><i>↑</i><span>←<b>●</b>→</span><i>↓</i></div>
|
||
<div className="face-diagram"><i className="triangle">{DEFAULT_CONTROLLER_GLYPHS.faceTop}</i><span><i className="square">{DEFAULT_CONTROLLER_GLYPHS.faceLeft}</i><b>●</b><i className="circle">{DEFAULT_CONTROLLER_GLYPHS.faceRight}</i></span><i className="cross">{DEFAULT_CONTROLLER_GLYPHS.faceBottom}</i></div>
|
||
</div>
|
||
<div className="mapping-list"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Confirm / cast Purify</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Right stick</b> Rotate camera</span><span><b>{DEFAULT_CONTROLLER_GLYPHS.start}</b> Pause / menu</span></div>
|
||
<div className="control-assurance"><i>✓</i><span><strong>No app focus required</strong><small>Native controller input routes through app-level actions.</small></span></div>
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) {
|
||
const hunter = useActiveHunter();
|
||
const accountId = useFrontendStore((state) => state.accountId);
|
||
const modeId = useFrontendStore((state) => state.selectedMode);
|
||
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
|
||
const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug);
|
||
const selectBoss = useFrontendStore((state) => state.selectBoss);
|
||
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
||
const navigate = useFrontendStore((state) => state.navigate);
|
||
const [message, setMessage] = useState("");
|
||
const [queueing, setQueueing] = useState(false);
|
||
const [queueElapsed, setQueueElapsed] = useState(0);
|
||
const queueActive = useRef(false);
|
||
const queueTicket = useRef<string | null>(null);
|
||
const queuePollTimer = useRef<number | null>(null);
|
||
const queueCpuTimer = useRef<number | null>(null);
|
||
const queueClockTimer = useRef<number | null>(null);
|
||
const mode = MODE_COPY[modeId];
|
||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||
const progress = hunter?.healers[hunter.activeClassId];
|
||
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
|
||
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
|
||
const isPve = modeId === "roguelike-pve";
|
||
const isRogueTrials = modeId === "rogue-trials";
|
||
const isPveRun = isPve || isRogueTrials;
|
||
const isDungeon = modeId === "dungeons";
|
||
const isHockey = modeId === "hockey-healing";
|
||
const isHockeyPvp = modeId === "hockey-healing-pvp";
|
||
const isBlockbreaker = modeId === "blockbreaker";
|
||
const isAetherAssault = modeId === "aether-assault";
|
||
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
|
||
const visibleBossIds = selectedBossGroup.bossIds;
|
||
const bossGridColumns = Math.min(2, visibleBossIds.length);
|
||
const selectBossGroup = (groupId: (typeof BOSS_GROUPS)[number]["id"]) => {
|
||
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
|
||
};
|
||
const clearQueueTimers = () => {
|
||
if (queuePollTimer.current !== null) window.clearTimeout(queuePollTimer.current);
|
||
if (queueCpuTimer.current !== null) window.clearTimeout(queueCpuTimer.current);
|
||
if (queueClockTimer.current !== null) window.clearInterval(queueClockTimer.current);
|
||
queuePollTimer.current = null;
|
||
queueCpuTimer.current = null;
|
||
queueClockTimer.current = null;
|
||
};
|
||
const completePvpQueue = (match: HockeyPvpMatchConfig) => {
|
||
if (!queueActive.current) return;
|
||
queueActive.current = false;
|
||
clearQueueTimers();
|
||
setQueueing(false);
|
||
setMessage(match.role === "cpu" ? `CPU rival found: ${match.opponentName}.` : `Matched with ${match.opponentName}.`);
|
||
onLaunch([hockeyPvpBossAt(match.seed, 0)], "initiate", match);
|
||
};
|
||
const fallbackToCpu = () => {
|
||
if (!queueActive.current) return;
|
||
const ticketId = queueTicket.current;
|
||
queueTicket.current = null;
|
||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||
completePvpQueue({
|
||
matchId: null,
|
||
seed: Math.max(1, Math.floor(Math.random() * 0xffffffff)),
|
||
opponentName: randomHockeyPvpCpuName(),
|
||
role: "cpu",
|
||
});
|
||
};
|
||
const cancelPvpQueue = () => {
|
||
if (!queueActive.current) return;
|
||
queueActive.current = false;
|
||
clearQueueTimers();
|
||
const ticketId = queueTicket.current;
|
||
queueTicket.current = null;
|
||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||
setQueueing(false);
|
||
setQueueElapsed(0);
|
||
setMessage("Matchmaking cancelled.");
|
||
};
|
||
const startPvpQueue = async () => {
|
||
if (!hunter || queueActive.current) return;
|
||
queueActive.current = true;
|
||
setQueueing(true);
|
||
setQueueElapsed(0);
|
||
setMessage(accountId ? "Searching online queue…" : "Offline queue: searching before CPU fallback…");
|
||
const startedAt = Date.now();
|
||
queueClockTimer.current = window.setInterval(() => setQueueElapsed(Date.now() - startedAt), 100);
|
||
queueCpuTimer.current = window.setTimeout(fallbackToCpu, HOCKEY_PVP_QUEUE_TIMEOUT_MS);
|
||
if (!accountId || !networkAppearsOnline()) return;
|
||
try {
|
||
const joined = await onlineRepository.joinHockeyPvpQueue(hunter.slotId, hunter.hunterName);
|
||
if (!queueActive.current) return;
|
||
queueTicket.current = joined.ticketId;
|
||
if (joined.match) {
|
||
completePvpQueue({
|
||
matchId: joined.match.id,
|
||
seed: joined.match.seed,
|
||
opponentName: joined.match.opponentName,
|
||
role: joined.match.role,
|
||
});
|
||
return;
|
||
}
|
||
const poll = async () => {
|
||
if (!queueActive.current || !queueTicket.current) return;
|
||
try {
|
||
const result = await onlineRepository.pollHockeyPvpQueue(queueTicket.current);
|
||
if (!queueActive.current) return;
|
||
if (result.match) {
|
||
completePvpQueue({
|
||
matchId: result.match.id,
|
||
seed: result.match.seed,
|
||
opponentName: result.match.opponentName,
|
||
role: result.match.role,
|
||
});
|
||
return;
|
||
}
|
||
} catch {
|
||
// Five-second CPU fallback remains authoritative during transient outages.
|
||
}
|
||
if (queueActive.current) queuePollTimer.current = window.setTimeout(poll, 350);
|
||
};
|
||
queuePollTimer.current = window.setTimeout(poll, 350);
|
||
} catch {
|
||
setMessage("Online queue unavailable. CPU fallback still searching…");
|
||
}
|
||
};
|
||
useEffect(() => () => {
|
||
queueActive.current = false;
|
||
clearQueueTimers();
|
||
const ticketId = queueTicket.current;
|
||
if (ticketId) void onlineRepository.cancelHockeyPvpQueue(ticketId).catch(() => undefined);
|
||
}, []);
|
||
const leaveMode = () => {
|
||
cancelPvpQueue();
|
||
navigate("home");
|
||
};
|
||
const launch = () => {
|
||
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
|
||
if (isHockey) return onLaunch(selectRandomBossPair(), "initiate");
|
||
if (isBlockbreaker) return onLaunch(selectRandomBossPair(), "initiate");
|
||
if (isAetherAssault) return onLaunch(selectRandomBossPair(), "initiate");
|
||
if (isHockeyPvp) return queueing ? cancelPvpQueue() : void startPvpQueue();
|
||
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
|
||
setMessage("Online matchmaking is not available for this mode yet.");
|
||
};
|
||
const actions = useMemo<MenuAction[]>(() => [
|
||
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
|
||
const groupColumns = 5;
|
||
const row = Math.floor(index / groupColumns);
|
||
const column = index % groupColumns;
|
||
const groupAt = (targetRow: number, targetColumn: number) => BOSS_GROUPS[targetRow * groupColumns + targetColumn];
|
||
|
||
return {
|
||
id: `boss-group-${group.id}`,
|
||
run: () => selectBossGroup(group.id),
|
||
neighbors: {
|
||
up: row > 0 ? `boss-group-${groupAt(row - 1, column)?.id}` : "back",
|
||
down: groupAt(row + 1, column)
|
||
? `boss-group-${groupAt(row + 1, column)?.id}`
|
||
: group.id === selectedBossGroup.id ? `boss-${selectedBossGroup.bossIds[0]}` : undefined,
|
||
left: column > 0 ? `boss-group-${groupAt(row, column - 1)?.id}` : undefined,
|
||
right: groupAt(row, column + 1) ? `boss-group-${groupAt(row, column + 1)?.id}` : undefined,
|
||
},
|
||
};
|
||
}) : []),
|
||
...(isDungeon ? visibleBossIds.map((bossId, index) => {
|
||
const row = Math.floor(index / bossGridColumns);
|
||
const column = index % bossGridColumns;
|
||
const bossAt = (targetRow: number, targetColumn: number) => visibleBossIds[targetRow * bossGridColumns + targetColumn];
|
||
|
||
return {
|
||
id: `boss-${bossId}`,
|
||
run: () => selectBoss(bossId),
|
||
neighbors: {
|
||
up: row > 0 ? `boss-${bossAt(row - 1, column)}` : `boss-group-${selectedBossGroup.id}`,
|
||
down: bossAt(row + 1, column) ? `boss-${bossAt(row + 1, column)}` : `difficulty-${DIFFICULTIES[0].slug}`,
|
||
left: column > 0 ? `boss-${bossAt(row, column - 1)}` : undefined,
|
||
right: bossAt(row, column + 1) ? `boss-${bossAt(row, column + 1)}` : undefined,
|
||
},
|
||
};
|
||
}) : []),
|
||
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||
id: `difficulty-${difficulty.slug}`,
|
||
run: () => selectDifficulty(difficulty.slug),
|
||
neighbors: {
|
||
left: index > 0 ? `difficulty-${DIFFICULTIES[index - 1].slug}` : `boss-${selectedBossId}`,
|
||
right: index < DIFFICULTIES.length - 1 ? `difficulty-${DIFFICULTIES[index + 1].slug}` : "launch",
|
||
up: `boss-${selectedBossId}`,
|
||
down: "launch",
|
||
},
|
||
})) : []),
|
||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
|
||
{ id: "back", run: leaveMode, neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
|
||
], [bossGridColumns, isAetherAssault, isBlockbreaker, isDungeon, isHockey, isHockeyPvp, isPveRun, modeId, navigate, onLaunch, queueing, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
|
||
const controller = useMenuController(actions, { onBack: leaveMode });
|
||
const launchLabel = isRogueTrials
|
||
? "Begin Rogue Trials"
|
||
: isPve
|
||
? "Begin RPG Roguelike"
|
||
: isHockey
|
||
? "Begin Hockey Healing"
|
||
: isBlockbreaker
|
||
? "Begin Blockbreaker"
|
||
: isAetherAssault
|
||
? "Begin Aether Assault"
|
||
: isHockeyPvp
|
||
? queueing ? "Cancel matchmaking" : "Enter online queue"
|
||
: isDungeon
|
||
? `Challenge ${selectedBoss.name}`
|
||
: "Enter matchmaking";
|
||
const contextRules = isDungeon
|
||
? [
|
||
[selectedBoss.name, selectedBoss.summary],
|
||
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
|
||
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
|
||
]
|
||
: isAetherAssault
|
||
? [
|
||
["Movement is the only arcade input", "Spellfire launches automatically down the five runway lanes while every healing and targeting control stays unchanged."],
|
||
["Formation pressure", "Eight arcane ships enter the first wave. Later formations grow to twenty, add armor, fire faster, and peel into diving attacks."],
|
||
["Heal through every hit", "Ship bolts and dive collisions damage only the healer. Endless bosses keep attacking the full party until the formation falls."],
|
||
]
|
||
: isBlockbreaker
|
||
? [
|
||
["Break linked colors", "Aim the puck into five-column rows. A hit removes its full orthogonally connected color cluster."],
|
||
["Accelerating wall", "Rows begin every 10 seconds, accelerate 10% each minute, and push survivors toward the danger line."],
|
||
["Heal under pressure", `Two bosses attack without pause. Each brick-wall breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member. The run ends when all four allies fall.`],
|
||
]
|
||
: isHockeyPvp
|
||
? [
|
||
["Normalized rivals", "Both parties use default base gear with all upgrade and infusion bonuses disabled. Boss order remains identical."],
|
||
["Escalating pressure", `Every boss kill by either party adds 5% global healing Dampening. Every net breach still deals ${HOCKEY_PVP_GOAL_DAMAGE} partywide damage.`],
|
||
["Online or CPU", "Queue searches online for five seconds. If no rival answers, a randomly named CPU healer takes far goal."],
|
||
]
|
||
: isHockey
|
||
? [
|
||
["Wide goal defense", "Healer owns near half. Intercept every incoming puck before it reaches the wide blue goal."],
|
||
["Pong rally", "Held left-stick direction controls return angle. Moving enemy paddle tracks the puck and strikes it back."],
|
||
["Unbroken boss fight", "Party fights two bosses on enemy half. Every kill awards loot and pet chance before replacement arrives."],
|
||
]
|
||
: isRogueTrials
|
||
? [
|
||
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
|
||
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
|
||
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
|
||
]
|
||
: isPve
|
||
? [
|
||
["Draft every run", "Choose four companions from three five-card waves, then build a six-slot spellbook from every enabled healer class."],
|
||
["Challenge hallways", "Brickbreaker, Hockey, and Aether Assault objectives connect boss rooms. Repeats raise their targets; failure still advances."],
|
||
["Run-only growth", "Boss chests upgrade owned spells, companions, or +0–+5 gear. Shop after each three-boss act, then face a finale."],
|
||
]
|
||
: [
|
||
["Draft a healing path", "Choose rites after every completed room."],
|
||
["Protect the formation", "Boss pressure changes around your build."],
|
||
["Bank collection drops", "Earned boss loot writes to active offline save."],
|
||
];
|
||
return (
|
||
<DualDisplayFrame
|
||
top={
|
||
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
|
||
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><ControllerButton id="back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={leaveMode}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton></header>
|
||
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
||
{isDungeon && (
|
||
<div className="boss-picker" aria-label="Choose boss encounter">
|
||
<div className="boss-picker-heading"><span>Choose a mechanic group</span></div>
|
||
<div className="boss-group-grid" aria-label="Choose boss group">
|
||
{BOSS_GROUPS.map((group) => (
|
||
<ControllerButton
|
||
key={group.id}
|
||
id={`boss-group-${group.id}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
className={`boss-group-choice ${group.id === selectedBossGroup.id ? "is-selected" : ""}`}
|
||
aria-pressed={group.id === selectedBossGroup.id}
|
||
onClick={() => selectBossGroup(group.id)}
|
||
>
|
||
<b>{group.letter}</b><span><strong>Group {group.letter}</strong><small>{group.name}</small></span>
|
||
</ControllerButton>
|
||
))}
|
||
</div>
|
||
<div className="boss-group-heading"><span>Group {selectedBossGroup.letter} · {selectedBossGroup.name}</span><small>{selectedBossGroup.coreMechanic} mechanics · {visibleBossIds.length} guardians</small></div>
|
||
<div className="boss-choice-grid">
|
||
{visibleBossIds.map((bossId) => {
|
||
const boss = BOSS_DEFINITIONS[bossId];
|
||
return (
|
||
<ControllerButton
|
||
key={bossId}
|
||
id={`boss-${bossId}`}
|
||
selectedId={controller.selectedId}
|
||
select={controller.select}
|
||
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
|
||
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
|
||
aria-pressed={selectedBossId === bossId}
|
||
onClick={() => selectBoss(bossId)}
|
||
>
|
||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanicIds.filter((id) => !bossMechanicIsPassive(id)).map(bossMechanicName).join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||
</ControllerButton>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{isDungeon && (
|
||
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
|
||
<span>Difficulty</span>
|
||
{DIFFICULTIES.map((difficulty) => <ControllerButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} selectedId={controller.selectedId} select={controller.select} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></ControllerButton>)}
|
||
</div>
|
||
)}
|
||
<ControllerButton id="launch" selectedId={controller.selectedId} select={controller.select} className={`mode-launch ${queueing ? "is-queueing" : ""}`} onClick={launch}><span>{launchLabel}</span><small>{queueing ? `CPU fallback in ${Math.max(0, ((HOCKEY_PVP_QUEUE_TIMEOUT_MS - queueElapsed) / 1000)).toFixed(1)}s` : `${mode.status} · ${DEFAULT_CONTROLLER_GLYPHS.confirm}`}</small></ControllerButton>
|
||
{message && <div className="front-notice">{message}</div>}
|
||
</FrontSurface>
|
||
}
|
||
bottom={
|
||
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
|
||
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
|
||
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
|
||
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>{isHockeyPvp ? "6 abilities · Base gear normalized · Controller ready" : `6 abilities · ${progress?.inventory.length ?? 0} class items · Controller ready`}</small></div>
|
||
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||
{isHockey && <div className="mode-loot-preview"><span>Every boss kill</span><b>Normal boss loot awarded</b><small>Guaranteed 1–3 group drops · Independent pet chance 1 in 500</small></div>}
|
||
{isBlockbreaker && <div className="mode-loot-preview"><span>Ranked records</span><b>Overall score · Bricks · Survival</b><small>10 points per brick ladder · +0.1× every 30 seconds</small></div>}
|
||
{isAetherAssault && <div className="mode-loot-preview"><span>Ranked record</span><b>Overall score · Wave at best</b><small>Kill streak raises multiplier · Ship damage resets it</small></div>}
|
||
{isHockeyPvp && <div className="mode-loot-preview"><span>Ranked records</span><b>Wins / losses · Lifetime PVP boss kills</b><small>Online leaderboards publish through active hunter save</small></div>}
|
||
</FrontSurface>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"], hockeyPvpMatch?: HockeyPvpMatchConfig) => void }) {
|
||
const screen = useFrontendStore((state) => state.screen);
|
||
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 />;
|
||
if (screen === "settings") return <SettingsScreen />;
|
||
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
|
||
return null;
|
||
}
|