Release v0.1.7 2026-07-12

This commit is contained in:
Warren H
2026-07-12 23:39:57 -04:00
parent 122f159b94
commit 77fd434226
31 changed files with 545 additions and 113 deletions
+37 -14
View File
@@ -6,6 +6,7 @@ import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../g
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -170,7 +171,7 @@ function BriefingPanel() {
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>START / ENTER</small></button>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -189,29 +190,51 @@ function BriefingPanel() {
);
}
function EndPanel() {
function EndPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const endlessChoiceSelection = useGameStore((state) => state.endlessChoiceSelection);
const setEndlessChoiceSelection = useGameStore((state) => state.setEndlessChoiceSelection);
const startRogueTrialsEndless = useGameStore((state) => state.startRogueTrialsEndless);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
const restart = useGameStore((state) => state.restart);
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
</div>
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
}
@@ -225,16 +248,16 @@ function IntermissionStatusPanel() {
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
<small>Use D-pad to choose · {DEFAULT_CONTROLLER_GLYPHS.confirm} to claim</small>
</div>
);
}
function CombatPanel() {
function CombatPanel({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel onExit={onExit} />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
@@ -340,7 +363,7 @@ const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
export function BottomScreen() {
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
@@ -358,13 +381,13 @@ export function BottomScreen() {
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel />}
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Encounter suspended</strong><small>START / ESC resumes · selects menu action</small>
<span>PAUSED</span><strong>Encounter suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { ROGUE_TRIALS_TRIO_ROUND, RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
import { HEALER_CLASSES } from "../game/healers";
import { isRunBuffInputLocked, useGameStore } from "../game/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
@@ -67,7 +68,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button>
)}
</div>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
<footer>{inputLocked ? <b>Choices ready in a moment</b> : <>{choices.length > 0 && <><b> / </b> Choose <i /></>} <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
@@ -45,7 +46,7 @@ export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: Reac
onClick={() => setActiveSurface(activeSurfaceRef.current === "top" ? "bottom" : "top")}
aria-label={activeSurface === "top" ? "Open tactical display" : "Return to main display"}
>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>SELECT / TAB</small>
<b>{activeSurface === "top" ? "Tactical" : "Main"}</b><small>{DEFAULT_CONTROLLER_GLYPHS.select} / TAB</small>
</button>
</div>
);
+37 -20
View File
@@ -37,6 +37,7 @@ import {
import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
@@ -76,7 +77,7 @@ function BrandMark({ compact = false }: { compact?: boolean }) {
}
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>;
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 LoginScreen() {
@@ -335,7 +336,7 @@ function SaveScreen() {
{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>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</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>
@@ -399,7 +400,6 @@ function HomeScreen() {
top={
<FrontSurface className="home-surface" ariaLabel="Main menu">
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
<div className="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)}>
@@ -441,6 +441,8 @@ function HomeScreen() {
);
}
type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless";
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
@@ -449,11 +451,11 @@ function ProfileScreen() {
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
const [collectionView, setCollectionView] = useState<"loot" | "trophies" | "stats">("trophies");
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
const [selectedStat, setSelectedStat] = useState<BossId | "roguelike">("roguelike");
const [selectedStat, setSelectedStat] = useState<ProfileStatId>("roguelike");
const [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
if (selectedStat === "roguelike" || selectedStat === "rogue-trials-endless" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]);
useEffect(() => {
@@ -467,7 +469,9 @@ function ProfileScreen() {
setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
: selectedStat === "rogue-trials-endless"
? onlineRepository.rogueTrialsEndlessLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => {
if (cancelled) return;
setLeaderboard(result);
@@ -484,12 +488,13 @@ function ProfileScreen() {
{ id: "view-stats", run: () => setCollectionView("stats"), neighbors: { left: "view-trophies", right: "view-loot", down: collectionView === "stats" ? "stat-roguelike" : undefined } },
{ id: "view-loot", run: () => setCollectionView("loot"), neighbors: { left: "view-stats" } },
...(collectionView === "stats" ? [
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: `stat-${collection.bosses[0].bossId}` } },
{ id: "stat-roguelike", run: () => setSelectedStat("roguelike"), neighbors: { up: "view-stats", down: "stat-rogue-trials-endless" } },
{ id: "stat-rogue-trials-endless", run: () => setSelectedStat("rogue-trials-endless"), neighbors: { up: "stat-roguelike", down: `stat-${collection.bosses[0].bossId}` } },
...collection.bosses.map((boss, index) => ({
id: `stat-${boss.bossId}`,
run: () => setSelectedStat(boss.bossId),
neighbors: {
up: index === 0 ? "stat-roguelike" : `stat-${collection.bosses[index - 1].bossId}`,
up: index === 0 ? "stat-rogue-trials-endless" : `stat-${collection.bosses[index - 1].bossId}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
},
})),
@@ -503,6 +508,16 @@ function ProfileScreen() {
const activeProgress = hunter.healers[hunter.activeClassId];
const earned = collection.drops.filter((drop) => drop.count > 0).length;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.count > 0).length;
const selectedStatValue = selectedStat === "roguelike"
? hunter.stats.highestRoguelikeRound
: selectedStat === "rogue-trials-endless"
? hunter.stats.highestRogueTrialsEndlessKills
: hunter.stats.bossKills[selectedStat] ?? 0;
const selectedStatLabel = selectedStat === "roguelike"
? "Roguelike rounds"
: selectedStat === "rogue-trials-endless"
? "Rogue Trials endless kills"
: BOSS_DEFINITIONS[selectedStat].name;
return (
<DualDisplayFrame
@@ -512,7 +527,7 @@ function ProfileScreen() {
<FocusButton id="view-trophies" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "trophies" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "trophies"} onClick={() => setCollectionView("trophies")}>Trophy Case</FocusButton>
<FocusButton id="view-stats" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "stats" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "stats"} onClick={() => setCollectionView("stats")}>Boss Stats</FocusButton>
<FocusButton id="view-loot" focusedId={controller.focusedId} focus={controller.focus} className={collectionView === "loot" ? "is-selected" : ""} role="tab" aria-selected={collectionView === "loot"} onClick={() => setCollectionView("loot")}>Group Loot</FocusButton>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
</div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
{collectionView === "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">
@@ -540,18 +555,19 @@ function ProfileScreen() {
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Each guardian keeps its own trophy.</strong><small>Defeat that boss for a 1 in 500 pet roll.</small></span></div>
</> : <>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Highest roguelike round {hunter.stats.highestRoguelikeRound}</b></div>
<div className="collection-heading boss-stats-heading"><span><small>Lifetime records · Overall leaderboards</small><h2>Boss Stats</h2></span><b>Endless best {hunter.stats.highestRogueTrialsEndlessKills} kills</b></div>
<div className="boss-stats-layout">
<section className="boss-stat-selector" aria-label="Boss statistic selection">
<FocusButton id="stat-roguelike" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "roguelike" ? "is-selected" : ""} onClick={() => setSelectedStat("roguelike")}><i></i><span><strong>Roguelike</strong><small>Highest round before defeat</small></span><b>{hunter.stats.highestRoguelikeRound}</b></FocusButton>
<FocusButton id="stat-rogue-trials-endless" focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === "rogue-trials-endless" ? "is-selected" : ""} onClick={() => setSelectedStat("rogue-trials-endless")}><i></i><span><strong>Trials Endless</strong><small>Most bosses in one run</small></span><b>{hunter.stats.highestRogueTrialsEndlessKills}</b></FocusButton>
{collection.bosses.map((boss) => <FocusButton key={boss.bossId} id={`stat-${boss.bossId}`} focusedId={controller.focusedId} focus={controller.focus} className={selectedStat === boss.bossId ? "is-selected" : ""} onClick={() => setSelectedStat(boss.bossId)}><i>{BOSS_DEFINITIONS[boss.bossId].icon}</i><span><strong>{boss.bossName}</strong><small>Lifetime boss kills</small></span><b>{boss.kills}</b></FocusButton>)}
</section>
<section className="leaderboard-panel" aria-label="Overall leaderboard">
<header><span><small>Overall Top 5</small><strong>{selectedStat === "roguelike" ? "Roguelike rounds" : BOSS_DEFINITIONS[selectedStat].name}</strong></span><b>{selectedStat === "roguelike" ? `${hunter.stats.highestRoguelikeRound} best` : `${hunter.stats.bossKills[selectedStat] ?? 0} kills`}</b></header>
<header><span><small>Overall Top 5</small><strong>{selectedStatLabel}</strong></span><b>{selectedStatValue} {selectedStat === "roguelike" ? "round" : "kills"}</b></header>
{leaderboardStatus ? <div className="leaderboard-status">{leaderboardStatus}</div> : <div className="leaderboard-rows">
{leaderboard?.top.length ? leaderboard.top.map((entry) => <div key={`${entry.username}-${entry.slotId}`} className={entry.username === accountId && entry.slotId === hunter.slotId ? "is-you" : ""}><b>#{entry.rank}</b><span><strong>{entry.hunterName}</strong><small>{entry.username}</small></span><em>{entry.value}</em></div>) : <div className="leaderboard-empty">No ranked hunters yet.</div>}
</div>}
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStat === "roguelike" ? hunter.stats.highestRoguelikeRound : hunter.stats.bossKills[selectedStat] ?? 0}</em></div>
<div className="leaderboard-self"><b>{leaderboard?.current ? `#${leaderboard.current.rank}` : "—"}</b><span><strong>Your rank · {hunter.hunterName}</strong><small>{accountId ?? "Offline hunter"}</small></span><em>{selectedStatValue}</em></div>
</section>
</div>
<div className="collection-note trophy-note"><i></i><span><strong>Rankings update with server saves.</strong><small>Top five always shown; your row stays visible at any rank.</small></span></div>
@@ -567,6 +583,7 @@ 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>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
<span><small>Endless best</small><strong>{hunter.stats.highestRogueTrialsEndlessKills}</strong></span>
</div>
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
@@ -701,7 +718,7 @@ function GearScreen() {
<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"><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>
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop 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")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
@@ -764,7 +781,7 @@ function GearScreen() {
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 group drops · autosave" : "Collect required group drops"}</small></FocusButton> : passiveContext ? <div className="gear-passive-context-action"><span>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === selectedPassive.id ? "Passive equipped" : "A · Equip selected passive"}</span><small>Applies at rank 1 next encounter.</small></div> : <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 group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
{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 group drops · autosave" : "Collect required group drops"}</small></FocusButton> : 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> : <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 group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
@@ -795,7 +812,7 @@ function SettingsScreen() {
<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>
<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")}>{DEFAULT_CONTROLLER_GLYPHS.back} · 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>
@@ -808,9 +825,9 @@ function SettingsScreen() {
<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 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>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>Right stick</b> Rotate camera</span><span><b>Start</b> Pause / menu</span></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 click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
@@ -908,7 +925,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
? [
["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."],
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."],
["Endless choice", "After the trio falls, quit with the clear or continue while every dead boss is replaced."],
]
: isPve
? [
@@ -925,7 +942,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
<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>
<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")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</FocusButton></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">
@@ -973,7 +990,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
{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>
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · {DEFAULT_CONTROLLER_GLYPHS.confirm}</small></FocusButton>
{message && <div className="front-notice">{message}</div>}
</FrontSurface>
}
+20 -12
View File
@@ -5,6 +5,7 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -109,6 +110,9 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -116,6 +120,8 @@ function PhaseOverlay() {
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
@@ -123,25 +129,25 @@ function PhaseOverlay() {
const title = phase === "briefing"
? room.name
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? "Five entered. Five endured."
: definitions.map((boss) => boss.failure).join(" ");
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
</div>
);
}
@@ -168,15 +174,15 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
onFocus={() => setPauseSelection("resume")}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>START / ESC</small></button>
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>A</small></button>
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Confirm</footer>
<footer><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm} / ENTER</b> Confirm</footer>
</div>
</div>
);
@@ -187,6 +193,8 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
@@ -197,11 +205,11 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>START / ESC</small></button>}
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<PhaseOverlay />
<PauseOverlay onExit={onExit} />