Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
|
||||
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import type { BossCollection, GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import type { BossId } from "../game/types";
|
||||
import { DualDisplayFrame } from "./DualDisplayFrame";
|
||||
|
||||
function FocusButton({
|
||||
id,
|
||||
focusedId,
|
||||
focus,
|
||||
className = "",
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; focusedId: string; focus: (id: string) => void }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`${className} ${focusedId === id ? "is-controller-focused" : ""}`}
|
||||
onFocus={(event) => { focus(id); props.onFocus?.(event); }}
|
||||
onPointerEnter={(event) => { focus(id); props.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>A</b> Select</span>{back && <span><b>B</b> Back</span>}<span><b>+</b> Navigate</span></div>;
|
||||
}
|
||||
|
||||
function LoginScreen() {
|
||||
const signIn = useFrontendStore((state) => state.signIn);
|
||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||||
const notice = useFrontendStore((state) => state.notice);
|
||||
const [hunterId, setHunterId] = useState("wayfinder");
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "sign-in", run: () => signIn(hunterId) },
|
||||
{ id: "offline", run: continueOffline },
|
||||
], [continueOffline, hunterId, signIn]);
|
||||
const controller = useMenuController(actions);
|
||||
|
||||
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>Your save always lives on this device. Sign in only when you want a second copy for PC ↔ AYN Thor handoff.</p>
|
||||
</div>
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
|
||||
<label htmlFor="hunter-id">Hunter ID</label>
|
||||
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
|
||||
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
|
||||
<span>Sign in & sync</span><small>Online saves enabled</small>
|
||||
</FocusButton>
|
||||
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
|
||||
<span>Continue with offline save</span><small>No account required</small>
|
||||
</FocusButton>
|
||||
</form>
|
||||
{notice && <div className="front-notice">{notice}</div>}
|
||||
<ControllerLegend />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
|
||||
<BrandMark compact />
|
||||
<div className="offline-promise">
|
||||
<span className="context-kicker">How saving works</span>
|
||||
<ol>
|
||||
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
|
||||
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li>
|
||||
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="device-route"><span>PC</span><i>↔</i><b>ONLINE COPY</b><i>↔</i><span>THOR</span></div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
|
||||
const save = slot.local;
|
||||
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
|
||||
return (
|
||||
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
|
||||
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</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" | null>(null);
|
||||
const [hunterName, setHunterName] = useState("");
|
||||
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
|
||||
const hasLocal = Boolean(selected.local);
|
||||
const hasOnline = Boolean(selected.online);
|
||||
|
||||
const finishCreation = () => {
|
||||
if (createSlot(selectedSlotId, hunterName)) {
|
||||
setHunterName("");
|
||||
setDialog(null);
|
||||
}
|
||||
};
|
||||
const openCreation = () => {
|
||||
setHunterName("");
|
||||
setDialog("create");
|
||||
};
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => dialog === "create"
|
||||
? [
|
||||
{ id: "confirm-create", run: finishCreation },
|
||||
{ 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) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
|
||||
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
|
||||
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
|
||||
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
|
||||
{ id: "copy", run: () => setDialog("copy"), enabled: hasLocal },
|
||||
{ id: "delete", run: () => setDialog("delete"), enabled: hasLocal },
|
||||
{ id: "back", run: () => navigate("login") },
|
||||
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
|
||||
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
|
||||
|
||||
const cloudStatus = !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}
|
||||
focused={controller.isFocused(`slot-${slot.id}`)}
|
||||
onSelect={() => selectSlot(slot.id)}
|
||||
onFocus={() => controller.focus(`slot-${slot.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
|
||||
{dialog && (
|
||||
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
|
||||
{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
|
||||
id="new-hunter-name"
|
||||
value={hunterName}
|
||||
maxLength={MAX_HUNTER_NAME_LENGTH}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
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">
|
||||
<FocusButton id="confirm-create" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit" disabled={!normalizeHunterName(hunterName)}>Create hunter</FocusButton>
|
||||
<FocusButton id="cancel-create" focusedId={controller.focusedId} focus={controller.focus} type="button" onClick={() => setDialog(null)}>Cancel</FocusButton>
|
||||
</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) => (
|
||||
<FocusButton key={slot.id} id={`copy-${slot.id}`} focusedId={controller.focusedId} focus={controller.focus} onClick={() => { copySlot(selectedSlotId, slot.id); setDialog(null); }}>
|
||||
Slot {slot.id}<small>{slot.local ? "Overwrite" : "Empty"}</small>
|
||||
</FocusButton>
|
||||
))}
|
||||
</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">
|
||||
<FocusButton id="confirm-delete" focusedId={controller.focusedId} focus={controller.focus} className="is-danger" onClick={() => { deleteSlot(selectedSlotId); setDialog(null); }}>Delete local</FocusButton>
|
||||
<FocusButton id="cancel-delete" focusedId={controller.focusedId} focus={controller.focus} onClick={() => setDialog(null)}>Cancel</FocusButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
|
||||
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
|
||||
<div className="selected-save-summary">
|
||||
{selected.local ? (
|
||||
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.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.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
|
||||
<div className="save-actions">
|
||||
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
|
||||
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>A</small>
|
||||
</FocusButton>
|
||||
<div className="sync-actions">
|
||||
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}>↑ Sync offline to server</FocusButton>
|
||||
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}>↓ Overwrite with online</FocusButton>
|
||||
</div>
|
||||
<div className="record-actions">
|
||||
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => setDialog("copy")}>Copy save</FocusButton>
|
||||
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => setDialog("delete")}>Delete save</FocusButton>
|
||||
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
|
||||
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
|
||||
{ id: "dungeons", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
|
||||
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||
];
|
||||
|
||||
function HomeScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const accountId = useFrontendStore((state) => state.accountId);
|
||||
const selectMode = useFrontendStore((state) => state.selectMode);
|
||||
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "dungeons", down: "roguelike-pvp" } },
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "profile", down: "class-shaman" } },
|
||||
...HEALER_CLASS_ORDER.map((classId, index) => ({
|
||||
id: `class-${classId}`,
|
||||
run: () => selectHealerClass(classId),
|
||||
neighbors: {
|
||||
left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
up: index === 2 ? "settings" : "profile",
|
||||
down: "change-save",
|
||||
},
|
||||
})),
|
||||
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-druid" } },
|
||||
], [navigate, 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-title"><span>Choose your hunt</span><h1>Where are you needed?</h1></div>
|
||||
<div className="mode-grid">
|
||||
{HOME_MODES.map((mode) => (
|
||||
<FocusButton key={mode.id} id={mode.id} focusedId={controller.focusedId} focus={controller.focus} className="mode-card" onClick={() => selectMode(mode.id)}>
|
||||
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b>›</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-secondary-actions">
|
||||
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i>♙</i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i>⚙</i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b>›</b></FocusButton>
|
||||
</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 <FocusButton key={classId} id={`class-${classId}`} focusedId={controller.focusedId} focus={controller.focus} 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>
|
||||
</FocusButton>;
|
||||
})}</div></div>
|
||||
<FocusButton id="change-save" focusedId={controller.focusedId} focus={controller.focus} className="change-save" onClick={() => navigate("saves")}><span>Change save slot</span><small>Last saved {formatSaveTimestamp(hunter.updatedAt)}</small></FocusButton>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const [bossId, setBossId] = useState(hunter?.collections[0].bossId ?? "");
|
||||
const collection = hunter?.collections.find((boss) => boss.bossId === bossId) ?? hunter?.collections[0];
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
|
||||
{ id: "back", run: () => navigate("home") },
|
||||
], [hunter?.collections, navigate]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
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;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</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 boss to reveal"}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="collection-note"><i>✦</i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
|
||||
<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>
|
||||
</div>
|
||||
<div className="boss-log"><span>Boss records</span>{hunter.collections.map((boss) => (
|
||||
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
|
||||
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossName] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
|
||||
</FocusButton>
|
||||
))}</div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) {
|
||||
return <FocusButton id={id} focusedId={focusedId} focus={focus} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></FocusButton>;
|
||||
}
|
||||
|
||||
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><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></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><FocusButton id="volume-down" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−</FocusButton><b>{settings.masterVolume}%</b><FocusButton id="volume-up" focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+</FocusButton></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} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("reducedMotion", !settings.reducedMotion)} /><SettingToggle id="numbers" label="Damage numbers" copy="Show combat values over units" value={settings.damageNumbers} focusedId={controller.focusedId} focus={controller.focus} onClick={() => updateSetting("damageNumbers", !settings.damageNumbers)} /><SettingToggle id="text" label="Large interface text" copy="Increase menu and tactical labels" value={settings.largeText} focusedId={controller.focusedId} focus={controller.focus} 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="y">Y</i><span><i className="x">X</i><b>●</b><i className="b">B</i></span><i className="a">A</i></div>
|
||||
</div>
|
||||
<div className="mapping-list"><span><b>A</b> Confirm / cast Purify</span><span><b>B</b> Back / cast Shield</span><span><b>D-Pad</b> Navigate / target party</span><span><b>Start</b> Pause / menu</span></div>
|
||||
<div className="control-assurance"><i>✓</i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
const modeId = useFrontendStore((state) => state.selectedMode);
|
||||
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
|
||||
const selectBoss = useFrontendStore((state) => state.selectBoss);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const [message, setMessage] = useState("");
|
||||
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 isPve = modeId === "roguelike-pve";
|
||||
const isDungeon = modeId === "dungeons";
|
||||
const launch = () => {
|
||||
if (isPve) return onLaunch(selectRandomBossPair());
|
||||
if (isDungeon) return onLaunch([selectedBossId]);
|
||||
setMessage("Online matchmaking connects here when game server is configured.");
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(isDungeon ? BOSS_ORDER.map((bossId, index) => ({
|
||||
id: `boss-${bossId}`,
|
||||
run: () => selectBoss(bossId),
|
||||
neighbors: {
|
||||
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
|
||||
},
|
||||
})) : []),
|
||||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
|
||||
const contextRules = isDungeon
|
||||
? [
|
||||
[selectedBoss.name, selectedBoss.summary],
|
||||
[selectedBoss.mechanics[0], selectedBoss.briefing],
|
||||
[selectedBoss.mechanics[1], "Controller-ready party behavior and full lower-display support."],
|
||||
]
|
||||
: isPve
|
||||
? [
|
||||
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
|
||||
["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."],
|
||||
["Roguelike foundation", "Three-choice buff drafts are next in development."],
|
||||
]
|
||||
: [
|
||||
["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><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<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">
|
||||
<span>Choose encounter</span>
|
||||
{BOSS_ORDER.map((bossId) => {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
return (
|
||||
<FocusButton
|
||||
key={bossId}
|
||||
id={`boss-${bossId}`}
|
||||
focusedId={controller.focusedId}
|
||||
focus={controller.focus}
|
||||
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.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
|
||||
{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>{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>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
const screen = useFrontendStore((state) => state.screen);
|
||||
if (screen === "login") return <LoginScreen />;
|
||||
if (screen === "saves") return <SaveScreen />;
|
||||
if (screen === "home") return <HomeScreen />;
|
||||
if (screen === "profile") return <ProfileScreen />;
|
||||
if (screen === "settings") return <SettingsScreen />;
|
||||
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user