Release v0.1.3 2026-07-11
This commit is contained in:
+242
-42
@@ -1,12 +1,35 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
|
||||
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } 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 type { 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 { 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,
|
||||
gearBonusText,
|
||||
gearUpgradeCosts,
|
||||
} from "../game/progression/gear";
|
||||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } 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 { DualDisplayFrame } from "./DualDisplayFrame";
|
||||
|
||||
@@ -330,15 +353,16 @@ function HomeScreen() {
|
||||
{ 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" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", 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",
|
||||
up: index === 0 ? "profile" : index === 1 ? "gear" : "settings",
|
||||
down: "change-save",
|
||||
},
|
||||
})),
|
||||
@@ -364,6 +388,7 @@ function HomeScreen() {
|
||||
</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="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend boss coins</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 />
|
||||
@@ -398,12 +423,13 @@ function HomeScreen() {
|
||||
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 collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
||||
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
|
||||
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
|
||||
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
|
||||
{ id: "back", run: () => navigate("home") },
|
||||
], [hunter?.collections, navigate]);
|
||||
], [collections, navigate]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
if (!hunter || !collection) return null;
|
||||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||||
@@ -422,6 +448,7 @@ function ProfileScreen() {
|
||||
<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>
|
||||
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -437,9 +464,9 @@ function ProfileScreen() {
|
||||
<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) => (
|
||||
<div className="boss-log"><span>Boss records</span>{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>
|
||||
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
|
||||
</FocusButton>
|
||||
))}</div>
|
||||
</FrontSurface>
|
||||
@@ -448,6 +475,140 @@ function ProfileScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
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 selectOwner = useFrontendStore((state) => state.selectGearOwner);
|
||||
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
|
||||
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
|
||||
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
|
||||
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 recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
|
||||
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
|
||||
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
|
||||
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 = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
|
||||
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 ? "back" : `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-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
|
||||
left: `slot-${selectedSlotId}`,
|
||||
},
|
||||
})),
|
||||
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
|
||||
id: `passive-${passive.id}`,
|
||||
run: () => installPassive(passive.id),
|
||||
enabled: passiveUnlocked,
|
||||
neighbors: {
|
||||
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
|
||||
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[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: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
|
||||
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
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>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></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));
|
||||
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
|
||||
})}
|
||||
</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];
|
||||
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} 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></FocusButton>;
|
||||
})}
|
||||
</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 {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</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) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} 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></FocusButton>)}
|
||||
</div>
|
||||
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</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` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
|
||||
<div className="gear-costs">
|
||||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||||
{(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" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} 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 coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} 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 coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
|
||||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</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>;
|
||||
}
|
||||
@@ -494,36 +655,65 @@ function SettingsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
|
||||
const hunter = useActiveHunter();
|
||||
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 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 isDungeon = modeId === "dungeons";
|
||||
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
|
||||
const launch = () => {
|
||||
if (isPve) return onLaunch(selectRandomBossPair());
|
||||
if (isDungeon) return onLaunch([selectedBossId]);
|
||||
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
|
||||
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
|
||||
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),
|
||||
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
|
||||
const column = Math.floor(index / bossGridRows);
|
||||
const row = index % bossGridRows;
|
||||
const neighborInColumn = (targetColumn: number) => {
|
||||
const columnStart = targetColumn * bossGridRows;
|
||||
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
|
||||
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
|
||||
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
|
||||
};
|
||||
|
||||
return {
|
||||
id: `boss-${bossId}`,
|
||||
run: () => selectBoss(bossId),
|
||||
neighbors: {
|
||||
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
|
||||
? `boss-${BOSS_ORDER[index + 1]}`
|
||||
: `difficulty-${DIFFICULTIES[0].slug}`,
|
||||
left: neighborInColumn(column - 1),
|
||||
right: neighborInColumn(column + 1),
|
||||
},
|
||||
};
|
||||
}) : []),
|
||||
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||||
id: `difficulty-${difficulty.slug}`,
|
||||
run: () => selectDifficulty(difficulty.slug),
|
||||
neighbors: {
|
||||
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
|
||||
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: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
|
||||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
|
||||
const contextRules = isDungeon
|
||||
@@ -536,7 +726,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
? [
|
||||
["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."],
|
||||
["Buff intermission", "Choose one of three stacking buffs after every cleared round."],
|
||||
]
|
||||
: [
|
||||
["Draft a healing path", "Choose rites after every completed room."],
|
||||
@@ -548,27 +738,35 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
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="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 className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{isDungeon && (
|
||||
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
|
||||
<span>Difficulty</span>
|
||||
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></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>
|
||||
@@ -577,21 +775,23 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
|
||||
<header className="context-header"><span>Run preparation</span><b>{mode.status.toUpperCase()}</b></header>
|
||||
<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>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
|
||||
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>1–3 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
|
||||
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => 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 === "gear") return <GearScreen />;
|
||||
if (screen === "settings") return <SettingsScreen />;
|
||||
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user