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 & { id: string; focusedId: string; focus: (id: string) => void }) { return ( ); } 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(() => 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 (
Hunter records

Choose a save

{accountId ? `● ${accountId}` : "○ OFFLINE"}
{slots.map((slot) => ( selectSlot(slot.id)} onFocus={() => controller.focus(`slot-${slot.id}`)} /> ))}
Autosave OFFLINE FIRST
{dialog && (
{dialog === "create" ? (
{ event.preventDefault(); finishCreation(); }}> New offline save

Name your hunter

This name identifies the character in local and online save lists.

setHunterName(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") setDialog(null); }} placeholder="Enter name" /> {normalizeHunterName(hunterName).length}/{MAX_HUNTER_NAME_LENGTH}
Create hunter setDialog(null)}>Cancel
) : dialog === "copy" ? ( <> Copy local save

Choose destination

Destination local save will be overwritten. Online copies stay unchanged.

{slots.filter((slot) => slot.id !== selectedSlotId).map((slot) => ( { copySlot(selectedSlotId, slot.id); setDialog(null); }}> Slot {slot.id}{slot.local ? "Overwrite" : "Empty"} ))}
) : ( <> Delete local save

Erase slot {selectedSlotId}?

Device copy will be removed. Existing online version remains available for download.

{ deleteSlot(selectedSlotId); setDialog(null); }}>Delete local setDialog(null)}>Cancel
)}
)} } bottom={
Slot {selectedSlotId}{cloudStatus}
{selected.local ? ( <>
{selected.local.hunterName[0]}
Local record

{selected.local.hunterName}

{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}

) : ( <>
Local record

Empty slot

Create a hunter or download an online version.

)}
{selected.online &&
ONLINE{selected.online.hunterName}
}
hasLocal ? playSlot(selectedSlotId) : openCreation()}> {hasLocal ? "Continue offline save" : "Create new hunter"}A
uploadSlot(selectedSlotId)}>↑ Sync offline to server downloadSlot(selectedSlotId)}>↓ Overwrite with online
setDialog("copy")}>Copy save setDialog("delete")}>Delete save navigate("login")}>Back
{notice || "All gameplay changes save to local storage automatically."}
} /> ); } 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(() => [ { 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 (
Welcome back, {hunter.hunterName}{accountId ? "● SYNC READY" : "○ OFFLINE"}
Choose your hunt

Where are you needed?

{HOME_MODES.map((mode) => ( selectMode(mode.id)}> {mode.icon}{mode.copy}{mode.label} ))}
navigate("profile")}>Hunter ProfileStats & collection log navigate("settings")}>SettingsAudio, display, controls
} bottom={
Active hunterLOCAL AUTOSAVE
{hunter.hunterName[0]}{activeHealer.icon}
Level {activeProgress.level} · {activeHealer.specialization}

{hunter.hunterName}

{hunter.location}

Boss kills{hunter.stats.totalBossKills} Flawless{hunter.stats.flawlessClears} Allies saved{hunter.stats.alliesSaved}
Choose healer
{HEALER_CLASS_ORDER.map((classId) => { const healer = HEALER_CLASSES[classId]; const progress = hunter.healers[classId]; return selectHealerClass(classId)}> {healer.icon}{healer.name}Level {progress.level} · {progress.inventory.length} items{classId === hunter.activeClassId ? "✓" : ""} ; })}
navigate("saves")}>Change save slotLast saved {formatSaveTimestamp(hunter.updatedAt)}
} /> ); } 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(() => [ ...(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 (
Hunter profile

Collection log

navigate("home")}>B · Back
Boss spoils

{collection.bossName}

{earned} / {collection.drops.length} discovered
{collection.drops.map((drop) => (
{drop.icon}{drop.count} {drop.rarity}{drop.name}

{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}

))}
Every drop stays counted.Duplicates increase quantity instead of disappearing.
} bottom={
{hunter.hunterName} · {activeHealer.name} statsLEVEL {activeProgress.level}
Total boss kills{hunter.stats.totalBossKills} Flawless clears{hunter.stats.flawlessClears} Allies saved{hunter.stats.alliesSaved} Healing done{hunter.stats.healingDone.toLocaleString()}
Boss records{hunter.collections.map((boss) => ( setBossId(boss.bossId)}> {boss.defeated ? "♜" : "?"}{boss.bossName}{hunter.stats.bossKills[boss.bossName] ?? 0} kills{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length} ))}
} /> ); } 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 {label}{copy}{value ? "ON" : "OFF"}; } 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(() => [ { 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 (
Field configuration

Settings

navigate("home")}>B · Back
Audio
Master volumeAll music, effects, and voice
updateSetting("masterVolume", Math.max(0, settings.masterVolume - 10))}>−{settings.masterVolume}% updateSetting("masterVolume", Math.min(100, settings.masterVolume + 10))}>+
Display & accessibility updateSetting("reducedMotion", !settings.reducedMotion)} /> updateSetting("damageNumbers", !settings.damageNumbers)} /> updateSetting("largeText", !settings.largeText)} />
{notice || "Settings write to offline storage immediately."}
} bottom={
ControllerBUILT-IN THOR PAD
YXBA
A Confirm / cast PurifyB Back / cast ShieldD-Pad Navigate / target partyStart Pause / menu
No click-to-focus requiredController input routes through app-level actions.
} /> ); } 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(() => [ ...(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 (
Game mode

{mode.title}

navigate("home")}>B · Back
{mode.eyebrow}

{mode.title}

{mode.description}

{mode.detail}
{isDungeon && (
Choose encounter {BOSS_ORDER.map((bossId) => { const boss = BOSS_DEFINITIONS[bossId]; return ( selectBoss(bossId)} > {boss.icon}{boss.name}{boss.mechanics.join(" · ")}{selectedBossId === bossId ? "✓" : ""} ); })}
)} {launchLabel}{mode.status} · A {message &&
{message}
} } bottom={
Run preparation{mode.status.toUpperCase()}
{contextRules.map(([title, copy], index) =>
0{index + 1}{title}{copy}
)}
Equipped role{healer.specialization} · Level {progress?.level ?? 1}6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready
} /> ); } export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) { const screen = useFrontendStore((state) => state.screen); if (screen === "login") return ; if (screen === "saves") return ; if (screen === "home") return ; if (screen === "profile") return ; if (screen === "settings") return ; if (screen === "mode") return ; return null; }