Release v0.1.6 2026-07-12
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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],
|
||||
|
||||
@@ -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>1–6</b> Cast</div>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user