Release v0.1.6 2026-07-12

This commit is contained in:
Warren H
2026-07-12 23:20:15 -04:00
parent 35553c18dd
commit 122f159b94
55 changed files with 2229 additions and 587 deletions
+57
View File
@@ -0,0 +1,57 @@
import { Canvas } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Suspense, useMemo } from "react";
import * as THREE from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, bossVisualUrl } from "../game/bossVisuals";
import type { BossId } from "../game/types";
function PortraitModel({ bossId }: { bossId: BossId }) {
const gltf = useGLTF(bossVisualUrl(bossId), false, true);
const model = useMemo(() => {
const clone = cloneSkeleton(gltf.scene);
clone.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(clone);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const scale = 2.35 / Math.max(size.x, size.y, size.z, 0.001);
clone.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = false;
object.receiveShadow = false;
}
});
return { clone, center, scale };
}, [gltf.scene]);
return (
<primitive
object={model.clone}
position={[-model.center.x * model.scale, -model.center.y * model.scale, -model.center.z * model.scale]}
rotation={[0, bossId === "bulldrome" ? 0.35 : ALTERNATE_BOSS_CONFIG[bossId].rotationOffset + 0.35, 0]}
scale={model.scale}
/>
);
}
export function BossTrophyPortrait({ bossId }: { bossId: BossId }) {
const boss = BOSS_DEFINITIONS[bossId];
return (
<div className="trophy-portrait" aria-label={`${boss.name} portrait`} role="img">
<Canvas
camera={{ position: [3.4, 2.35, 4.8], zoom: 70, near: 0.1, far: 30 }}
dpr={1}
frameloop="demand"
gl={{ alpha: true, antialias: true, powerPreference: "low-power" }}
orthographic
>
<ambientLight intensity={1.9} />
<directionalLight color="#fff3cf" intensity={3.2} position={[3, 5, 4]} />
<directionalLight color={boss.accent} intensity={2.1} position={[-4, 2, -2]} />
<Suspense fallback={null}><PortraitModel bossId={bossId} /></Suspense>
</Canvas>
<span aria-hidden="true">{boss.icon}</span>
</div>
);
}
+21 -6
View File
@@ -1,25 +1,39 @@
import { RUN_BUFFS, bossHealthMultiplier, effectiveRunBuffRank, formatRunBuffEffect } from "../game/roguelike";
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 { useGameStore } from "../game/store";
import { isRunBuffInputLocked, useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const healerClassId = useGameStore((state) => state.healerClassId);
const runBuffRanks = useGameStore((state) => state.runBuffRanks);
const passiveRunBuffId = useGameStore((state) => state.passiveRunBuffId);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const inputUnlockAt = useGameStore((state) => state.runBuffInputUnlockAt);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const continueRun = useGameStore((state) => state.continueRoguelikeRound);
const nextRound = round + 1;
const nextBossCount = runMode === "rogue-trials" && nextRound === ROGUE_TRIALS_TRIO_ROUND ? 3 : 2;
const abilities = HEALER_CLASSES[healerClassId].abilities;
const [inputLocked, setInputLocked] = useState(() => isRunBuffInputLocked(useGameStore.getState()));
useEffect(() => {
const remaining = inputUnlockAt - Date.now();
setInputLocked(remaining > 0);
if (remaining <= 0) return;
const timer = window.setTimeout(() => setInputLocked(false), remaining);
return () => window.clearTimeout(timer);
}, [inputUnlockAt]);
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<div className={`buff-draft ${inputLocked ? "is-input-locked" : ""} ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`} aria-busy={inputLocked}>
<header>
<span>Round {round} cleared</span>
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
<p>Claim required. Round {nextRound} begins with {nextBossCount === 3 ? "an unseen trio" : "two new bosses"} at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className={`buff-choice-grid choice-count-${choices.length}`}>
{choices.length > 0 ? choices.map((buffId) => {
@@ -35,6 +49,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
@@ -44,7 +59,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button>
);
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun}>
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
@@ -52,7 +67,7 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
</button>
)}
</div>
<footer>{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>A / ENTER</b> {choices.length > 0 ? "Claim" : "Continue"}</>}</footer>
</div>
);
}
+154 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -19,8 +19,10 @@ import {
GEAR_STAT_LABELS,
MAX_GEAR_LEVEL,
canAffordGearUpgrade,
canUpgradeGearSlot,
gearBonusText,
gearUpgradeCosts,
type GearOwnerId,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
import {
@@ -33,8 +35,11 @@ import {
passiveInfusionUnlocked,
} from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting";
import { onlineRepository, type LeaderboardResult } from "../frontend/onlineRepository";
import { DualDisplayFrame } from "./DualDisplayFrame";
const BossTrophyPortrait = lazy(() => import("./BossTrophyPortrait").then((module) => ({ default: module.BossTrophyPortrait })));
function FocusButton({
id,
focusedId,
@@ -75,6 +80,7 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
}
function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
@@ -83,6 +89,12 @@ function LoginScreen() {
const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const restoreStarted = useRef(false);
useEffect(() => {
if (restoreStarted.current) return;
restoreStarted.current = true;
void restoreSession();
}, [restoreSession]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
@@ -115,6 +127,9 @@ function LoginScreen() {
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("username")}
autoComplete="username"
maxLength={20}
minLength={3}
pattern="[A-Za-z0-9_]+"
required
/>
<label htmlFor="account-password">Password</label>
@@ -127,6 +142,8 @@ function LoginScreen() {
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")}
autoComplete="current-password"
maxLength={128}
minLength={10}
required
/>
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
@@ -150,8 +167,8 @@ function LoginScreen() {
<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>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Account is secured by the TrueNAS game server.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Upload or download any of your three server save slots.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
@@ -339,6 +356,7 @@ function SaveScreen() {
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
{ id: "roguelike-pve", icon: "✦", label: "PVE", copy: "Randomized roguelike runs" },
{ id: "rogue-trials", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
{ 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" },
@@ -351,10 +369,11 @@ function HomeScreen() {
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-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "roguelike-pvp" } },
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "stadium-pvp" } },
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", 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: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "rogue-trials", down: "settings" } },
{ 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" } },
@@ -424,37 +443,119 @@ function HomeScreen() {
function ProfileScreen() {
const hunter = useActiveHunter();
const accountId = useFrontendStore((state) => state.accountId);
const navigate = useFrontendStore((state) => state.navigate);
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
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 [leaderboard, setLeaderboard] = useState<LeaderboardResult | null>(null);
const [leaderboardStatus, setLeaderboardStatus] = useState("");
useEffect(() => {
if (selectedStat === "roguelike" || collection?.bosses.some((boss) => boss.bossId === selectedStat)) return;
setSelectedStat(collection?.bosses[0]?.bossId ?? "roguelike");
}, [collection, selectedStat]);
useEffect(() => {
if (!hunter || collectionView !== "stats") return;
if (!accountId) {
setLeaderboard(null);
setLeaderboardStatus("Sign in to view overall rankings.");
return;
}
let cancelled = false;
setLeaderboardStatus("Loading overall rankings…");
const request = selectedStat === "roguelike"
? onlineRepository.roguelikeLeaderboard(hunter.slotId)
: onlineRepository.bossLeaderboard(selectedStat, hunter.slotId);
void request.then((result) => {
if (cancelled) return;
setLeaderboard(result);
setLeaderboardStatus("");
}).catch((error) => {
if (cancelled) return;
setLeaderboard(null);
setLeaderboardStatus(error instanceof Error ? error.message : "Leaderboard unavailable.");
});
return () => { cancelled = true; };
}, [accountId, collectionView, hunter, selectedStat]);
const actions = useMemo<MenuAction[]>(() => [
{ id: "view-trophies", run: () => setCollectionView("trophies"), neighbors: { right: "view-stats" } },
{ 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}` } },
...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}`,
down: index === collection.bosses.length - 1 ? `group-${collection.groupId}` : `stat-${collection.bosses[index + 1].bossId}`,
},
})),
] : []),
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
{ id: "back", run: () => navigate("home") },
], [collections, navigate]);
], [collection, collectionView, 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;
const trophiesEarned = collection.bosses.filter((boss) => boss.pet.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>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
<div className="collection-grid">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
<header className="front-screen-header profile-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><div className="profile-view-tabs" role="tablist" aria-label="Collection view">
<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>
{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">
{collection.drops.map((drop) => (
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
<div className="collection-note"><i></i><span><strong>Boss pets stay individual.</strong><small>Open Trophy Case to inspect every guardian pet.</small></span></div>
</> : collectionView === "trophies" ? <>
<div className="collection-heading trophy-heading"><span><small>Boss pets · 1 in 500 per victory</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{trophiesEarned} / {collection.bosses.length} trophies lit</b></div>
<div className={`trophy-case trophy-count-${collection.bosses.length}`}>
{collection.bosses.map((boss) => {
const owned = boss.pet.count > 0;
return <article key={boss.bossId} className={`boss-trophy ${owned ? "is-owned" : "is-locked"}`} style={{ "--boss-accent": BOSS_DEFINITIONS[boss.bossId].accent } as React.CSSProperties}>
<Suspense fallback={<div className="trophy-portrait trophy-portrait-fallback" role="img" aria-label={`${boss.bossName} portrait`}><span>{BOSS_DEFINITIONS[boss.bossId].icon}</span></div>}><BossTrophyPortrait bossId={boss.bossId} /></Suspense>
<div className="trophy-plaque"><small>{owned ? "Pet secured" : "Pet undiscovered"}</small><strong>{boss.bossName}</strong><span>{boss.kills} kills · {boss.pet.chance}</span></div>
<b className="trophy-state">{owned ? `Owned${boss.pet.count > 1 ? ` ×${boss.pet.count}` : ""}` : "Locked"}</b>
</article>;
})}
</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="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>
{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>
{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>
</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>
</>}
</FrontSurface>
}
bottom={
@@ -465,6 +566,7 @@ function ProfileScreen() {
<span><small>Flawless clears</small><strong>{hunter.stats.flawlessClears}</strong></span>
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
<span><small>Highest roguelike round</small><strong>{hunter.stats.highestRoguelikeRound}</strong></span>
</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)}>
@@ -497,9 +599,23 @@ function GearScreen() {
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const upgradeReadiness = useMemo(() => {
const owners = new Set<GearOwnerId>();
const slots = new Set<string>();
if (!hunter) return { owners, slots };
for (const ownerId of GEAR_OWNER_ORDER) {
for (const slotId of GEAR_SLOT_ORDER) {
if (!canUpgradeGearSlot(hunter.gearProgress, hunter.materials, ownerId, slotId)) continue;
owners.add(ownerId);
slots.add(`${ownerId}:${slotId}`);
}
}
return { owners, slots };
}, [hunter]);
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
const canUpgrade = upgradeReadiness.slots.has(`${selectedOwnerId}:${selectedSlotId}`);
const infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
@@ -590,14 +706,16 @@ function GearScreen() {
<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>;
const upgradeReady = upgradeReadiness.owners.has(ownerId);
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_OWNER_LABELS[ownerId]}${upgradeReady ? ", upgrade available" : ""}`} className={`${ownerId === selectedOwnerId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></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>;
const upgradeReady = upgradeReadiness.slots.has(`${selectedOwnerId}:${slotId}`);
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} aria-label={`${GEAR_SLOT_LABELS[slotId]} +${progress.level}${upgradeReady ? ", upgrade available" : ""}`} className={`${slotId === selectedSlotId ? "is-selected" : ""} ${upgradeReady ? "is-upgrade-ready" : ""}`} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
})}
</section>
{workshopMode === "upgrade" ? <article className="gear-preview">
@@ -692,7 +810,7 @@ function SettingsScreen() {
<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="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="control-assurance"><i></i><span><strong>No click-to-focus required</strong><small>Controller input routes through app-level actions.</small></span></div>
</FrontSurface>
}
@@ -715,6 +833,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isRogueTrials = modeId === "rogue-trials";
const isPveRun = isPve || isRogueTrials;
const isDungeon = modeId === "dungeons";
const selectedBossGroup = BOSS_GROUP_BY_ID[selectedBoss.groupId];
const visibleBossIds = selectedBossGroup.bossIds;
@@ -723,9 +843,9 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
selectBoss(BOSS_GROUP_BY_ID[groupId].bossIds[0]);
};
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isPveRun) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
setMessage("Online matchmaking is not available for this mode yet.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_GROUPS.map((group, index) => {
@@ -775,15 +895,21 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-group-${selectedBossGroup.id}` } : { down: "launch" } },
], [bossGridColumns, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
], [bossGridColumns, isDungeon, isPveRun, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossGroup, selectedBossId, selectedDifficultySlug, visibleBossIds]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const launchLabel = isRogueTrials ? "Begin Rogue Trials" : isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
? [
[selectedBoss.name, selectedBoss.summary],
[bossMechanicName(selectedBoss.mechanicIds[0]), selectedBoss.briefing],
[bossMechanicName(selectedBoss.mechanicIds[1]), "Controller-ready party behavior and full lower-display support."],
]
: isRogueTrials
? [
["Four dual rounds", "Clear four randomized pairs while drafting one stacking buff after each win."],
["Unseen trio finale", "Round 5 selects three bosses that have not appeared earlier in that run."],
["Trial victory", "Defeat all three final bosses together to complete Rogue Trials."],
]
: isPve
? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
+44 -180
View File
@@ -6,7 +6,19 @@ import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import {
CAMERA_FOCUS_HEIGHT,
CAMERA_LOOK_AHEAD,
CAMERA_ORBIT_DISTANCE,
DEFAULT_CAMERA_PITCH,
DEFAULT_CAMERA_YAW,
setCameraRelativeMovement,
updateCameraOrbit,
type CameraOrbitState,
type PlanarMovement,
} from "../game/cameraOrbit";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
@@ -17,43 +29,8 @@ import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossCanTrackTarget } from "./boss/bossDeathVisuals";
const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "bulldrome"
| "sandglass-scorpion"
| "cragclaw-crab"
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
@@ -428,6 +405,8 @@ function PlayerCharacter() {
const castingUntil = useRef(0);
const instantCastTrigger = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
const cameraOrbit = useRef<CameraOrbitState>({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH });
const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
useEffect(() => {
const start = useGameStore.getState().partyPositions.aelia;
@@ -451,7 +430,11 @@ function PlayerCharacter() {
const nudgeX = Number(key === "d") - Number(key === "a");
const nudgeZ = Number(key === "s") - Number(key === "w");
if (!nudgeX && !nudgeZ) return;
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw);
const next = clampToArena([
group.current.position.x + cameraRelativeMovement.current.x * 0.18,
group.current.position.z + cameraRelativeMovement.current.z * 0.18,
]);
group.current.position.x = next[0];
group.current.position.z = next[1];
setPlayerPosition([group.current.position.x, group.current.position.z]);
@@ -476,9 +459,16 @@ function PlayerCharacter() {
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
const controller = getControllerMovement();
inputX += controller.x;
inputZ += controller.y;
inputX += controller.moveX;
inputZ += controller.moveY;
}
const controller = getControllerMovement();
if (state.phase === "combat" && !state.paused) {
updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta);
}
setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw);
inputX = cameraRelativeMovement.current.x;
inputZ = cameraRelativeMovement.current.z;
const length = Math.hypot(inputX, inputZ);
if (length > 0.05) {
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
@@ -518,9 +508,20 @@ function PlayerCharacter() {
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
desiredCameraPosition.set(group.current.position.x * 0.45, 5.1, group.current.position.z + 7.7);
const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE;
const sinYaw = Math.sin(cameraOrbit.current.yaw);
const cosYaw = Math.cos(cameraOrbit.current.yaw);
desiredCameraPosition.set(
group.current.position.x + sinYaw * horizontalDistance,
CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
group.current.position.z + cosYaw * horizontalDistance,
);
camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
camera.lookAt(group.current.position.x * 0.55, 0.65, group.current.position.z - 2.8);
camera.lookAt(
group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD,
CAMERA_FOCUS_HEIGHT,
group.current.position.z - cosYaw * CAMERA_LOOK_AHEAD,
);
broadcastTimer.current += delta;
if (broadcastTimer.current > 0.15) {
@@ -651,6 +652,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const motion = current.motion;
targetPosition.set(motion.position[0], 0.03, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return;
if (motion.mode === "stacking") {
group.current.rotation.y += (Math.PI * 2 / 5) * delta;
@@ -678,145 +680,6 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
);
}
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
interface AlternateBossConfig {
url: string;
scale: number;
idle: string;
move: string;
attack: string;
special: string;
death: string;
light: string;
rotationOffset: number;
prototype?: boolean;
floating?: boolean;
}
const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
idle: "Idle",
move: "Burrow",
attack: "Eruption",
special: "Hourglass",
death: "Death",
light: "#e9b94f",
rotationOffset: 0,
},
"cragclaw-crab": {
url: CRAGCLAW_URL,
scale: 1.2,
idle: "Idle",
move: "Walk",
attack: "Bite_Front",
special: "Bite_InPlace",
death: "Death",
light: "#49d5df",
rotationOffset: 0,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
floating: true,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
floating: true,
},
"crystal-bat-matriarch": {
url: CRYSTAL_BAT_MATRIARCH_URL,
scale: 0.828,
idle: "Idle",
move: "Swoop",
attack: "SonicPulse",
special: "MirrorShatter",
death: "Death",
light: "#8eeaff",
rotationOffset: 0,
floating: true,
},
"stormwool-alpaca": {
url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true,
},
"cluckhorn-colossus": {
url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true,
},
"ashwing-demon": {
url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true,
},
"riftclaw-demon": {
url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true,
},
"tempestscale-dragon": {
url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true,
},
emberfox: {
url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true,
},
"mirelord-frog": {
url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true,
},
"stonebreaker-giant": {
url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true,
},
"glub-sovereign": {
url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true,
},
"scrapking-goblin": {
url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true,
},
"warcaller-orc": {
url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true,
},
"tuskmaw-orc": {
url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true,
},
"broodfang-spider": {
url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true,
},
"silkfang-spider": {
url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true,
},
"thorncrown-stag": {
url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true,
},
"sky-totem": {
url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true,
},
"razorcrest-raptor": {
url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true,
},
"bristlequake-boar": {
url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true,
},
"moonfang-wolf": {
url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true,
},
"frostmaw-yeti": {
url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true,
},
"rimeclaw-yeti": {
url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true,
},
};
function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
@@ -879,6 +742,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const floatingHeight = config.floating ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
if (!bossCanTrackTarget(current.boss.hp)) return;
let targetAngle = Math.atan2(
state.partyPositions.brann[0] - motion.position[0],
+7 -3
View File
@@ -81,7 +81,7 @@ function BossBar() {
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-dual" : ""}`}>
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
@@ -108,6 +108,7 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
@@ -115,6 +116,9 @@ 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 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;
if (phase === "combat") return null;
const title = phase === "briefing"
? room.name
@@ -122,7 +126,7 @@ function PhaseOverlay() {
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
? `${briefingMode} · ${room.biome}`
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
@@ -193,7 +197,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<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>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
+135 -5
View File
@@ -1,10 +1,12 @@
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { useLayoutEffect, useRef, type ComponentType } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store";
import type { BossMotionMode, MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
@@ -26,12 +28,91 @@ const INWARD_ARROW_SHAPE = new THREE.Shape()
.lineTo(-0.29, 0.04)
.lineTo(-0.13, 0.04)
.lineTo(-0.13, -0.32);
const SOUL_SIPHON_GUIDANCE_HEIGHT = 2.2;
const SOUL_SIPHON_GUIDANCE_COLOR = "#9dff78";
const SOUL_SIPHON_GUIDANCE_OUTLINE = "#2b210b";
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion;
}
function bossHpAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
}
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
if (!motion) return current;
let earliest = current;
for (const telegraph of motion.poolTelegraphs) {
const siphon = telegraph.kind === "soul-siphon" && !telegraph.resolved ? telegraph.soulSiphon : undefined;
if (siphon && (!earliest || siphon.nextDamageAt < earliest.nextDamageAt)) earliest = siphon;
}
return earliest;
}
function activeSoulSiphonGuidance(state: GameStoreState) {
if (state.phase !== "combat") return undefined;
let earliest = state.boss.hp > 0 ? earliestSoulSiphonInMotion(state.bossMotion) : undefined;
for (const entry of state.additionalBosses) {
if (entry.boss.hp > 0) earliest = earliestSoulSiphonInMotion(entry.motion, earliest);
}
return earliest;
}
function WorldDirectionIndicator({
origin,
destination,
height,
color,
}: {
origin: WorldPosition;
destination: WorldPosition;
height: number;
color: string;
}) {
const group = useRef<THREE.Group>(null);
const fill = useRef<THREE.MeshBasicMaterial>(null);
const reducedMotion = document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches;
useFrame(({ clock }) => {
if (!group.current) return;
const wave = reducedMotion ? 0.5 : (Math.sin(clock.elapsedTime * 5.2) + 1) * 0.5;
group.current.position.set(origin[0], height + (reducedMotion ? 0 : (wave - 0.5) * 0.12), origin[1]);
group.current.rotation.y = angleTo(origin, destination) + Math.PI;
group.current.scale.setScalar(1.55 + wave * 0.16);
if (fill.current) fill.current.opacity = 0.82 + wave * 0.18;
});
return (
<group ref={group} position={[origin[0], height, origin[1]]} rotation={[0, angleTo(origin, destination) + Math.PI, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]} scale={1.28} renderOrder={30}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial color={SOUL_SIPHON_GUIDANCE_OUTLINE} transparent opacity={0.92} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={31}>
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
<meshBasicMaterial ref={fill} color={color} transparent opacity={1} depthTest={false} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
function SoulSiphonGuidanceIndicator() {
const guidance = useGameStore(activeSoulSiphonGuidance);
const playerPosition = useGameStore((state) => state.partyPositions.aelia);
if (!guidance) return null;
return (
<WorldDirectionIndicator
origin={playerPosition}
destination={guidance.wardPosition}
height={SOUL_SIPHON_GUIDANCE_HEIGHT}
color={SOUL_SIPHON_GUIDANCE_COLOR}
/>
);
}
export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -644,14 +725,63 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
PooledMechanicIndicators,
];
function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const defeated = useGameStore((state) => bossHpAt(state, bossIndex) <= 0);
const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
const [retainIndicators, setRetainIndicators] = useState(!defeated);
const patchMeshOpacity = useCallback((child: THREE.Object3D) => {
if (!(child instanceof THREE.Mesh) || patchedMeshes.current.has(child)) return;
patchedMeshes.current.add(child);
const previousBeforeRender = child.onBeforeRender;
const previousAfterRender = child.onAfterRender;
const materials = Array.isArray(child.material) ? child.material : [child.material];
const sourceOpacities = new Float32Array(materials.length);
child.onBeforeRender = (renderer, scene, camera, geometry, material, renderGroup) => {
previousBeforeRender.call(child, renderer, scene, camera, geometry, material, renderGroup);
for (let index = 0; index < materials.length; index += 1) {
sourceOpacities[index] = materials[index].opacity;
materials[index].opacity *= opacity.current;
}
};
child.onAfterRender = (renderer, scene, camera, geometry, material, renderGroup) => {
previousAfterRender.call(child, renderer, scene, camera, geometry, material, renderGroup);
for (let index = 0; index < materials.length; index += 1) materials[index].opacity = sourceOpacities[index];
};
}, []);
useEffect(() => {
if (!defeated) {
setRetainIndicators(true);
return;
}
const timeout = window.setTimeout(() => setRetainIndicators(false), BOSS_INDICATOR_DEATH_FADE_MS);
return () => window.clearTimeout(timeout);
}, [defeated]);
useFrame((_, delta) => {
if (!group.current) return;
opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta);
group.current.visible = opacity.current > 0;
group.current.traverse(patchMeshOpacity);
});
return (
<group ref={group}>
{retainIndicators && BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
);
}
export function BossMechanicIndicators() {
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
return (
<>
<SoulSiphonGuidanceIndicator />
{Array.from({ length: bossCount }, (_, bossIndex) => (
<group key={bossIndex}>
{BOSS_MECHANIC_INDICATORS.map((Indicator) => <Indicator key={Indicator.name} bossIndex={bossIndex} />)}
</group>
<BossMechanicIndicatorSet key={bossIndex} bossIndex={bossIndex} />
))}
</>
);
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
it("stops target tracking as soon as boss health reaches zero", () => {
expect(bossCanTrackTarget(1)).toBe(true);
expect(bossCanTrackTarget(0)).toBe(false);
expect(bossCanTrackTarget(-1)).toBe(false);
});
it("fades mechanic indicators to zero over the death transition", () => {
const halfway = advanceBossIndicatorOpacity(1, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000);
expect(halfway).toBeCloseTo(0.5);
expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
});
+10
View File
@@ -0,0 +1,10 @@
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export function bossCanTrackTarget(hp: number) {
return hp > 0;
}
export function advanceBossIndicatorOpacity(current: number, defeated: boolean, deltaSeconds: number) {
if (!defeated) return 1;
return Math.max(0, current - (deltaSeconds * 1_000) / BOSS_INDICATOR_DEATH_FADE_MS);
}