new help section explaining classes and their mechanics
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
|
||||
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
|
||||
import type { BottomTab, PartyMember } from "../game/types";
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { aetherShipColor } from "./aetherAssaultVisuals";
|
||||
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
|
||||
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
|
||||
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
@@ -56,6 +57,8 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
||||
const selected = useGameStore((state) => state.selectedMemberId === member.id);
|
||||
const selectMember = useGameStore((state) => state.selectMember);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
|
||||
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
|
||||
const knockedRemaining = Math.max(0, member.knockedUntil - time);
|
||||
const barrier = useGameStore((state) => state.barrier);
|
||||
@@ -71,9 +74,10 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
|
||||
className={`party-frame ${selected ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
|
||||
onClick={() => selectMember(member.id)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
|
||||
>
|
||||
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
|
||||
<span className="party-data">
|
||||
@@ -82,6 +86,7 @@ function PartyFrame({ member }: { member: PartyMember }) {
|
||||
<small>{currentAction ?? member.className}</small>
|
||||
</span>
|
||||
<span className="effect-stack">
|
||||
{beaconed && <i className="effect beacon-effect" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}>✦</i>}
|
||||
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}>◇</i>}
|
||||
{activeHealingEffects.map((effect) => {
|
||||
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
|
||||
@@ -385,7 +390,7 @@ function MapPanel() {
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
|
||||
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
|
||||
</div>
|
||||
@@ -432,7 +437,7 @@ function MapPanel() {
|
||||
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
|
||||
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
|
||||
</div>
|
||||
@@ -515,7 +520,7 @@ function MapPanel() {
|
||||
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
|
||||
))}
|
||||
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
|
||||
{barrier.expiresAt > time && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
|
||||
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
|
||||
</svg>
|
||||
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
|
||||
</div>
|
||||
@@ -557,8 +562,8 @@ function MapPanel() {
|
||||
className="map-barrier"
|
||||
cx={120 + barrier.center[0] * 7}
|
||||
cy={143 + barrier.center[1] * 5.3}
|
||||
rx="21"
|
||||
ry="15.9"
|
||||
rx={BARRIER_RADIUS * 7}
|
||||
ry={BARRIER_RADIUS * 5.3}
|
||||
/>
|
||||
)}
|
||||
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
|
||||
|
||||
+175
-31
@@ -13,6 +13,7 @@ import {
|
||||
} from "../frontend/profileSections";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER, isHealerClassId } from "../game/healers";
|
||||
import { HEALER_GUIDES } from "../game/healerGuides";
|
||||
import { ABILITY_ORDER } from "../game/data";
|
||||
import { BOSS_DEFINITIONS, BOSS_GROUP_BY_ID, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
import { bossMechanicIsPassive, bossMechanicName } from "../game/bosses/mechanicPool";
|
||||
@@ -77,14 +78,15 @@ function ControllerButton({
|
||||
onClick,
|
||||
onPointerEnter,
|
||||
onFocus: _onFocus,
|
||||
trackPointer = true,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; selectedId: string; select: (id: string) => void }) {
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { id: string; selectedId: string; select: (id: string) => void; trackPointer?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`${className} ${selectedId === id ? "is-controller-selected" : ""}`}
|
||||
onClick={(event) => { select(id); onClick?.(event); }}
|
||||
onPointerEnter={(event) => { select(id); onPointerEnter?.(event); }}
|
||||
onPointerEnter={(event) => { if (trackPointer) select(id); onPointerEnter?.(event); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -503,39 +505,46 @@ function SaveScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const HOME_MODES: { id: GameModeId; icon: string; label: string; copy: string }[] = [
|
||||
{ id: "roguelike-pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" },
|
||||
{ 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: "hockey-healing", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" },
|
||||
{ id: "hockey-healing-pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" },
|
||||
{ id: "blockbreaker", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" },
|
||||
{ id: "aether-assault", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" },
|
||||
{ id: "roguelike-pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||
{ id: "stadium-pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||
const HOME_MODES: { id: GameModeId; category: "pve" | "pvp"; icon: string; label: string; copy: string }[] = [
|
||||
{ id: "roguelike-pve", category: "pve", icon: "✦", label: "RPG Roguelike", copy: "Draft party, spells, gear, and route" },
|
||||
{ id: "rogue-trials", category: "pve", icon: "Ⅲ", label: "Rogue Trials", copy: "Four rounds, then a boss trio" },
|
||||
{ id: "dungeons", category: "pve", icon: "♜", label: "Dungeons", copy: "Choose your boss encounter" },
|
||||
{ id: "hockey-healing", category: "pve", icon: "◌", label: "Hockey Healing", copy: "Defend goal under boss pressure" },
|
||||
{ id: "hockey-healing-pvp", category: "pvp", icon: "◇", label: "Healing Hockey PVP", copy: "Online mirrored healer duel" },
|
||||
{ id: "blockbreaker", category: "pve", icon: "▦", label: "Blockbreaker", copy: "Break color walls while healing" },
|
||||
{ id: "aether-assault", category: "pve", icon: "⌁", label: "Aether Assault", copy: "Auto-fire through arcane formations" },
|
||||
{ id: "roguelike-pvp", category: "pvp", icon: "⚔", label: "Roguelike PvP", copy: "Draft, race, sabotage" },
|
||||
{ id: "stadium-pvp", category: "pvp", icon: "◉", label: "Stadium PvP", copy: "Prepared 5v5 rounds" },
|
||||
];
|
||||
|
||||
const HOME_MODE_SECTIONS = [
|
||||
{ id: "pve", title: "PVE Modes", copy: "Cooperative expeditions", modes: HOME_MODES.filter((mode) => mode.category === "pve") },
|
||||
{ id: "pvp", title: "PVP Modes", copy: "Competitive arenas", modes: HOME_MODES.filter((mode) => mode.category === "pvp") },
|
||||
] as const;
|
||||
|
||||
function HomeScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const accountId = useFrontendStore((state) => state.accountId);
|
||||
const selectMode = useFrontendStore((state) => state.selectMode);
|
||||
const selectHealerClass = useFrontendStore((state) => state.selectHealerClass);
|
||||
const openAppearanceLab = useFrontendStore((state) => state.openAppearanceLab);
|
||||
const openClassHelp = useFrontendStore((state) => state.openClassHelp);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { right: "rogue-trials", down: "hockey-healing" } },
|
||||
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "dungeons", down: "hockey-healing-pvp" } },
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "rogue-trials", down: "blockbreaker" } },
|
||||
{ id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { right: "hockey-healing-pvp", up: "roguelike-pve", down: "roguelike-pvp" } },
|
||||
{ id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "hockey-healing", right: "blockbreaker", up: "rogue-trials", down: "stadium-pvp" } },
|
||||
{ id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "hockey-healing-pvp", up: "dungeons", down: "aether-assault" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { right: "stadium-pvp", up: "hockey-healing", down: "profile" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", right: "aether-assault", up: "hockey-healing-pvp", down: "gear" } },
|
||||
{ id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "stadium-pvp", up: "blockbreaker", down: "settings" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "appearance" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "stadium-pvp", left: "profile", down: "settings" } },
|
||||
{ id: "appearance", run: openAppearanceLab, neighbors: { up: "profile", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "gear", left: "appearance", down: "class-druid" } },
|
||||
{ id: "roguelike-pve", run: () => selectMode("roguelike-pve"), neighbors: { left: "roguelike-pve", right: "rogue-trials", down: "dungeons" } },
|
||||
{ id: "rogue-trials", run: () => selectMode("rogue-trials"), neighbors: { left: "roguelike-pve", right: "hockey-healing-pvp", down: "hockey-healing" } },
|
||||
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "dungeons", right: "hockey-healing", up: "roguelike-pve", down: "blockbreaker" } },
|
||||
{ id: "hockey-healing", run: () => selectMode("hockey-healing"), neighbors: { left: "dungeons", right: "roguelike-pvp", up: "rogue-trials", down: "aether-assault" } },
|
||||
{ id: "blockbreaker", run: () => selectMode("blockbreaker"), neighbors: { left: "blockbreaker", right: "aether-assault", up: "dungeons", down: "profile" } },
|
||||
{ id: "aether-assault", run: () => selectMode("aether-assault"), neighbors: { left: "blockbreaker", right: "stadium-pvp", up: "hockey-healing", down: "appearance" } },
|
||||
{ id: "hockey-healing-pvp", run: () => selectMode("hockey-healing-pvp"), neighbors: { left: "rogue-trials", right: "hockey-healing-pvp", down: "roguelike-pvp" } },
|
||||
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "hockey-healing", right: "roguelike-pvp", up: "hockey-healing-pvp", down: "stadium-pvp" } },
|
||||
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "aether-assault", right: "stadium-pvp", up: "roguelike-pvp", down: "class-help" } },
|
||||
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "blockbreaker", left: "class-help", right: "gear", down: "class-priest" } },
|
||||
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "aether-assault", left: "profile", right: "appearance", down: "class-druid" } },
|
||||
{ id: "appearance", run: openAppearanceLab, neighbors: { up: "aether-assault", left: "gear", right: "settings", down: "class-priest" } },
|
||||
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "appearance", right: "class-help", down: "class-druid" } },
|
||||
{ id: "class-help", run: openClassHelp, neighbors: { up: "stadium-pvp", left: "settings", right: "profile", down: "class-chronomancer" } },
|
||||
...HEALER_CLASS_ORDER.map((classId, index) => ({
|
||||
id: `class-${classId}`,
|
||||
run: () => selectHealerClass(classId),
|
||||
@@ -547,7 +556,7 @@ function HomeScreen() {
|
||||
},
|
||||
})),
|
||||
{ id: "change-save", run: () => navigate("saves"), neighbors: { up: "class-chronomancer" } },
|
||||
], [navigate, openAppearanceLab, selectHealerClass, selectMode]);
|
||||
], [navigate, openAppearanceLab, openClassHelp, selectHealerClass, selectMode]);
|
||||
const controller = useMenuController(actions, { columns: 2, onBack: () => navigate("saves") });
|
||||
if (!hunter) return null;
|
||||
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
|
||||
@@ -558,11 +567,18 @@ function HomeScreen() {
|
||||
top={
|
||||
<FrontSurface className="home-surface" ariaLabel="Main menu">
|
||||
<header className="home-header"><BrandMark compact /><span>Welcome back, <b>{hunter.hunterName}</b></span><i>{accountId ? "● SYNC READY" : "○ OFFLINE"}</i></header>
|
||||
<div className="mode-grid">
|
||||
{HOME_MODES.map((mode) => (
|
||||
<ControllerButton key={mode.id} id={mode.id} selectedId={controller.selectedId} select={controller.select} className={`mode-card mode-card-${mode.id}`} onClick={() => selectMode(mode.id)}>
|
||||
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b>›</b>
|
||||
</ControllerButton>
|
||||
<div className="home-mode-sections">
|
||||
{HOME_MODE_SECTIONS.map((section) => (
|
||||
<section key={section.id} className={`home-mode-section is-${section.id}`} aria-labelledby={`home-${section.id}-heading`}>
|
||||
<header><span><small>{section.copy}</small><strong id={`home-${section.id}-heading`}>{section.title}</strong></span><b>{section.modes.length} ACTIVITIES</b></header>
|
||||
<div className={`mode-grid mode-grid-${section.id}`}>
|
||||
{section.modes.map((mode) => (
|
||||
<ControllerButton key={mode.id} id={mode.id} selectedId={controller.selectedId} select={controller.select} className={`mode-card mode-card-${mode.id}`} onClick={() => selectMode(mode.id)}>
|
||||
<i>{mode.icon}</i><span><small>{mode.copy}</small><strong>{mode.label}</strong></span><b>›</b>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="home-secondary-actions">
|
||||
@@ -570,6 +586,7 @@ function HomeScreen() {
|
||||
<ControllerButton id="gear" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="appearance" selectedId={controller.selectedId} select={controller.select} onClick={openAppearanceLab}><i>♜</i><span><strong>Appearance Lab</strong><small>Build and preview your healer</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="settings" selectedId={controller.selectedId} select={controller.select} onClick={() => navigate("settings")}><i>⚙</i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b>›</b></ControllerButton>
|
||||
<ControllerButton id="class-help" selectedId={controller.selectedId} select={controller.select} onClick={openClassHelp}><i>?</i><span><strong>Class Help</strong><small>Abilities, rotations, synergies</small></span><b>›</b></ControllerButton>
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
@@ -600,6 +617,132 @@ function HomeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClassHelpScreen() {
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const classId = useFrontendStore((state) => state.guideClassId);
|
||||
const selectedAbilityId = useFrontendStore((state) => state.guideAbilityId);
|
||||
const selectClass = useFrontendStore((state) => state.selectGuideClass);
|
||||
const selectAbility = useFrontendStore((state) => state.selectGuideAbility);
|
||||
const healer = HEALER_CLASSES[classId];
|
||||
const guide = HEALER_GUIDES[classId];
|
||||
const selectedAbility = healer.abilities[selectedAbilityId];
|
||||
const selectedGuide = guide.abilityGuides[selectedAbilityId];
|
||||
const classIndex = HEALER_CLASS_ORDER.indexOf(classId);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "guide-back", run: () => navigate("home"), neighbors: { right: "guide-class-priest", down: "guide-class-priest" } },
|
||||
...HEALER_CLASS_ORDER.map((candidate, index) => ({
|
||||
id: `guide-class-${candidate}`,
|
||||
run: () => selectClass(candidate),
|
||||
neighbors: {
|
||||
left: `guide-class-${HEALER_CLASS_ORDER[index === 0 ? HEALER_CLASS_ORDER.length - 1 : index - 1]}`,
|
||||
right: `guide-class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
|
||||
up: "guide-back",
|
||||
down: `guide-${ABILITY_ORDER[Math.min(index, ABILITY_ORDER.length - 1)]}`,
|
||||
},
|
||||
})),
|
||||
...ABILITY_ORDER.map((abilityId, index) => ({
|
||||
id: `guide-${abilityId}`,
|
||||
run: () => selectAbility(abilityId),
|
||||
neighbors: {
|
||||
left: `guide-${ABILITY_ORDER[index % 3 === 0 ? index + 2 : index - 1]}`,
|
||||
right: `guide-${ABILITY_ORDER[index % 3 === 2 ? index - 2 : index + 1]}`,
|
||||
up: index < 3 ? `guide-class-${classId}` : `guide-${ABILITY_ORDER[index - 3]}`,
|
||||
down: index < 3 ? `guide-${ABILITY_ORDER[index + 3]}` : "guide-back",
|
||||
},
|
||||
})),
|
||||
], [classId, navigate, selectAbility, selectClass]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: `guide-class-${classId}`,
|
||||
onBack: () => navigate("home"),
|
||||
});
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
<FrontSurface
|
||||
className="class-help-surface"
|
||||
ariaLabel="Healing class guide"
|
||||
>
|
||||
<header className="front-screen-header class-help-header">
|
||||
<BrandMark compact />
|
||||
<div><span>Field manual</span><h1>Class Help</h1></div>
|
||||
<small>{classIndex + 1} / {HEALER_CLASS_ORDER.length}</small>
|
||||
<ControllerButton id="guide-back" selectedId={controller.selectedId} select={controller.select} className="header-back" onClick={() => navigate("home")}>{DEFAULT_CONTROLLER_GLYPHS.back} · Back</ControllerButton>
|
||||
</header>
|
||||
<nav className="guide-class-tabs" aria-label="Healing classes">
|
||||
{HEALER_CLASS_ORDER.map((candidate) => {
|
||||
const candidateHealer = HEALER_CLASSES[candidate];
|
||||
return (
|
||||
<ControllerButton
|
||||
key={candidate}
|
||||
id={`guide-class-${candidate}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={candidate === classId ? "is-selected" : ""}
|
||||
style={{ "--guide-color": candidateHealer.color } as React.CSSProperties}
|
||||
aria-pressed={candidate === classId}
|
||||
onClick={() => selectClass(candidate)}
|
||||
>
|
||||
<i>{candidateHealer.icon}</i><span><strong>{candidateHealer.name}</strong><small>{HEALER_GUIDES[candidate].role}</small></span>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<section className="guide-class-intro" style={{ "--guide-color": healer.color } as React.CSSProperties}>
|
||||
<i>{healer.icon}</i>
|
||||
<span><small>{healer.specialization} · {guide.learningCurve}</small><h2>{guide.role}</h2><p>{healer.description}</p></span>
|
||||
<b>{healer.secondaryResourceName ? `${healer.resourceName} + ${healer.secondaryResourceName}` : healer.resourceName}</b>
|
||||
</section>
|
||||
<div className="guide-ability-grid" aria-label={`${healer.name} abilities`}>
|
||||
{ABILITY_ORDER.map((abilityId) => {
|
||||
const ability = healer.abilities[abilityId];
|
||||
return (
|
||||
<ControllerButton
|
||||
key={abilityId}
|
||||
id={`guide-${abilityId}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={`guide-ability-card ${selectedAbilityId === abilityId ? "is-selected" : ""}`}
|
||||
style={{ "--ability-color": ability.color } as React.CSSProperties}
|
||||
aria-pressed={selectedAbilityId === abilityId}
|
||||
trackPointer={false}
|
||||
onClick={() => selectAbility(abilityId)}
|
||||
>
|
||||
<span className="guide-ability-icon"><b>{ability.icon}</b><small>{ability.gamepad}</small></span>
|
||||
<span className="guide-ability-copy"><strong>{ability.name}</strong><small>{ability.description}</small></span>
|
||||
<span className="guide-ability-meta"><b>{ability.cooldown ? `${ability.cooldown}s CD` : "No cooldown"}</b><b>{ability.mana} {healer.resourceName}</b><b>{ability.targeting}</b></span>
|
||||
</ControllerButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="class-help-context" bottom ariaLabel={`${healer.name} play guide`}>
|
||||
<header className="context-header"><span>{healer.name} field guide</span><b>{guide.learningCurve.toUpperCase()}</b></header>
|
||||
<section className="guide-context-class" style={{ "--guide-color": healer.color } as React.CSSProperties}>
|
||||
<i>{healer.icon}</i><span><small>{healer.specialization}</small><h2>{guide.role}</h2><p>{guide.resourceGuide}</p></span>
|
||||
</section>
|
||||
<section className="guide-core-loop">
|
||||
<header><span>Core loop</span><b>01—03</b></header>
|
||||
{guide.coreLoop.map((step, index) => <p key={step}><i>0{index + 1}</i><span>{step}</span></p>)}
|
||||
</section>
|
||||
<section className="guide-selected-detail" style={{ "--ability-color": selectedAbility.color } as React.CSSProperties}>
|
||||
<header><i>{selectedAbility.icon}</i><span><small>Selected ability · {selectedAbility.gamepad}</small><h3>{selectedAbility.name}</h3></span></header>
|
||||
<p>{selectedGuide.useWhen}</p>
|
||||
<aside><b>Field tip</b><span>{selectedGuide.fieldTip}</span></aside>
|
||||
<div className="guide-synergy-list"><span>Synergies</span>{selectedGuide.synergies.map((synergy) => {
|
||||
const pairedAbility = healer.abilities[synergy.with];
|
||||
return <article key={`${synergy.with}-${synergy.summary}`}><i>{pairedAbility.icon}</i><span><strong>{pairedAbility.name}</strong><small>{synergy.summary}</small></span><b>{synergy.kind === "mechanic" ? "DIRECT" : "COMBO"}</b></article>;
|
||||
})}</div>
|
||||
</section>
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const accountId = useFrontendStore((state) => state.accountId);
|
||||
@@ -1718,6 +1861,7 @@ export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[],
|
||||
if (screen === "login") return <LoginScreen />;
|
||||
if (screen === "saves") return <SaveScreen />;
|
||||
if (screen === "home") return <HomeScreen />;
|
||||
if (screen === "class-help") return <ClassHelpScreen />;
|
||||
if (screen === "profile") return <ProfileScreen />;
|
||||
if (screen === "gear") return <GearScreen />;
|
||||
if (screen === "appearance") return <AppearanceScreen />;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
|
||||
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
HEALER_VISUAL_PROFILES,
|
||||
type HealerVisualProfile,
|
||||
} from "../game/healerVisuals";
|
||||
import { isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
import {
|
||||
CHARACTER_MODEL_MODE,
|
||||
type CharacterAppearanceV1,
|
||||
@@ -73,7 +74,7 @@ import {
|
||||
} from "../game/weaponCatalog";
|
||||
import { resolveCharacterEquipment } from "../game/characterEquipment";
|
||||
import { HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BARRIER_RADIUS, useGameStore } from "../game/store";
|
||||
import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types";
|
||||
import { BossRoom } from "./BossRoom";
|
||||
import { HealerClassAccessory } from "./HealerClassAccessory";
|
||||
@@ -117,6 +118,8 @@ import {
|
||||
GAMEPLAY_FRAME_INTERVAL_MS,
|
||||
isOutcomePhase,
|
||||
outcomeElapsedAfterPhaseChange,
|
||||
resetSceneClockForMode,
|
||||
sceneCanvasFrameloop,
|
||||
SIMULATION_STEP_SECONDS,
|
||||
selectSceneRenderMode,
|
||||
startSceneFrameLoop,
|
||||
@@ -965,8 +968,8 @@ const MIN_RENDER_DPR = 1;
|
||||
const MAX_RENDER_DPR = 1.25;
|
||||
|
||||
/**
|
||||
* Owns manual rendering only while gameplay or a finite outcome animation is active.
|
||||
* Static scenes use R3F demand rendering; suspended scenes render nothing.
|
||||
* Requests demand frames only while gameplay or a finite outcome animation is
|
||||
* active. Keeping one R3F clock domain avoids demand/manual RAF handoff races.
|
||||
*/
|
||||
function SceneFrameScheduler({
|
||||
dpr,
|
||||
@@ -981,9 +984,8 @@ function SceneFrameScheduler({
|
||||
onDprChange: (next: number) => void;
|
||||
onOutcomeComplete: () => void;
|
||||
}) {
|
||||
const { advance, invalidate } = useThree();
|
||||
const { clock, get, invalidate } = useThree();
|
||||
const simulationAccumulator = useRef(0);
|
||||
const manualTimeSeconds = useRef(0);
|
||||
const outcomeElapsedSeconds = useRef(0);
|
||||
const previousOutcomePhase = useRef<OutcomePhase | null>(null);
|
||||
const slowFrameMs = useRef(0);
|
||||
@@ -991,7 +993,9 @@ function SceneFrameScheduler({
|
||||
const dprRef = useRef(dpr);
|
||||
dprRef.current = dpr;
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (mode === "suspended") get().internal.frames = 0;
|
||||
resetSceneClockForMode(clock, mode);
|
||||
outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange(
|
||||
previousOutcomePhase.current,
|
||||
outcomePhase,
|
||||
@@ -1005,7 +1009,6 @@ function SceneFrameScheduler({
|
||||
}
|
||||
|
||||
if (mode === "static") {
|
||||
manualTimeSeconds.current = 0;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
@@ -1013,12 +1016,10 @@ function SceneFrameScheduler({
|
||||
|
||||
return startSceneFrameLoop({
|
||||
mode,
|
||||
initialManualTimeSeconds: manualTimeSeconds.current,
|
||||
initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
onFrame: (sample) => {
|
||||
manualTimeSeconds.current = sample.manualTimeSeconds;
|
||||
if (mode === "active") {
|
||||
const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds);
|
||||
simulationAccumulator.current = stepResult.remainderSeconds;
|
||||
@@ -1047,19 +1048,74 @@ function SceneFrameScheduler({
|
||||
outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds;
|
||||
}
|
||||
|
||||
advance(sample.manualTimeSeconds, true);
|
||||
invalidate();
|
||||
},
|
||||
onOutcomeComplete,
|
||||
});
|
||||
}, [advance, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
|
||||
}, [clock, get, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function BeaconOfLightMarker() {
|
||||
const marker = useRef<THREE.Group>(null);
|
||||
const glow = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!marker.current) return;
|
||||
const pulse = Math.sin(clock.elapsedTime * 4.4);
|
||||
marker.current.position.y = 2.62 + pulse * 0.06;
|
||||
marker.current.rotation.y = clock.elapsedTime * 0.7;
|
||||
if (glow.current) glow.current.opacity = 0.78 + pulse * 0.12;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={marker} position={[0, 2.62, 0]}>
|
||||
<pointLight color="#ffe989" intensity={3.2} distance={3.2} decay={2} />
|
||||
<mesh>
|
||||
<octahedronGeometry args={[0.18, 0]} />
|
||||
<meshBasicMaterial
|
||||
ref={glow}
|
||||
color="#fff4af"
|
||||
transparent
|
||||
opacity={0.9}
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.29, 0.025, 8, 24]} />
|
||||
<meshBasicMaterial
|
||||
color="#ffd45e"
|
||||
transparent
|
||||
opacity={0.8}
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, -0.38, 0]}>
|
||||
<coneGeometry args={[0.23, 0.72, 12, 1, true]} />
|
||||
<meshBasicMaterial
|
||||
color="#ffe68a"
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={0.16}
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const animationTrigger = useRef(0);
|
||||
const beaconed = useGameStore((state) => isBeaconOfLightTarget(memberId, state.healerMechanic, state.time));
|
||||
const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype);
|
||||
const visualMemberId: MemberId = visualArchetype === "knight"
|
||||
? "brann"
|
||||
@@ -1126,6 +1182,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId={memberId} visualMemberId={visualMemberId} animationState={animationState} animationTrigger={animationTrigger} />
|
||||
{beaconed && <BeaconOfLightMarker />}
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -1144,6 +1201,7 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
|
||||
const scenePulse = useGameStore((state) => state.scenePulse);
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
|
||||
const beaconed = useGameStore((state) => isBeaconOfLightTarget("aelia", state.healerMechanic, state.time));
|
||||
const rpgEncounterKey = useGameStore((state) => {
|
||||
const phase = state.rpgRun?.phase;
|
||||
if (state.runMode !== "rpg-roguelike") return null;
|
||||
@@ -1163,12 +1221,26 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
|
||||
const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
|
||||
const hockeyAimMovement = useRef<PlanarMovement>({ x: 0, z: -1 });
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const start = useGameStore.getState().partyPositions.aelia;
|
||||
group.current?.position.set(start[0], 0.025, start[1]);
|
||||
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(
|
||||
start[0] + sinYaw * horizontalDistance,
|
||||
CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
|
||||
start[1] + cosYaw * horizontalDistance,
|
||||
);
|
||||
camera.position.copy(desiredCameraPosition);
|
||||
camera.lookAt(
|
||||
start[0] - sinYaw * CAMERA_LOOK_AHEAD,
|
||||
CAMERA_FOCUS_HEIGHT,
|
||||
start[1] - cosYaw * CAMERA_LOOK_AHEAD,
|
||||
);
|
||||
keys.current.clear();
|
||||
broadcastTimer.current = 0;
|
||||
}, [rpgEncounterKey]);
|
||||
}, [camera, desiredCameraPosition, rpgEncounterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (["periodic-heal", "protective", "cleanse", "group-heal", "field"].includes(scenePulse.kind)) {
|
||||
@@ -1326,6 +1398,7 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
|
||||
animationState={animationState}
|
||||
animationTrigger={animationTrigger}
|
||||
/>
|
||||
{beaconed && <BeaconOfLightMarker />}
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -2098,15 +2171,15 @@ function BarrierField() {
|
||||
return (
|
||||
<group ref={group} visible={false}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[3, 64]} />
|
||||
<circleGeometry args={[BARRIER_RADIUS, 64]} />
|
||||
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[2.92, 3.05, 64]} />
|
||||
<ringGeometry args={[BARRIER_RADIUS - 0.08, BARRIER_RADIUS + 0.05, 64]} />
|
||||
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[1.82, 1.9, 48]} />
|
||||
<ringGeometry args={[BARRIER_RADIUS * 0.607, BARRIER_RADIUS * 0.633, 48]} />
|
||||
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
|
||||
</mesh>
|
||||
{Array.from({ length: 8 }, (_, index) => {
|
||||
@@ -2114,7 +2187,7 @@ function BarrierField() {
|
||||
return (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
|
||||
position={[Math.sin(angle) * BARRIER_RADIUS * 0.783, 0.02, Math.cos(angle) * BARRIER_RADIUS * 0.783]}
|
||||
rotation={[-Math.PI / 2, 0, angle]}
|
||||
>
|
||||
<ringGeometry args={[0.09, 0.16, 6]} />
|
||||
@@ -2782,7 +2855,7 @@ export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAp
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
frameloop={renderMode === "static" ? "demand" : "never"}
|
||||
frameloop={sceneCanvasFrameloop(renderMode)}
|
||||
shadows="basic"
|
||||
dpr={dpr}
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import type * as THREE from "three";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { HealerVisualProfile } from "../game/healerVisuals";
|
||||
|
||||
function RelicMaterial({ color }: { color: string }) {
|
||||
@@ -15,6 +15,40 @@ function RelicMaterial({ color }: { color: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DawnNimbus({ accentColor, secondaryColor }: { accentColor: string; secondaryColor: string }) {
|
||||
const rays = useRef<THREE.InstancedMesh>(null);
|
||||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!rays.current) return;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const angle = index * Math.PI / 4;
|
||||
transform.position.set(Math.sin(angle) * 0.54, Math.cos(angle) * 0.54, 0);
|
||||
transform.rotation.set(0, 0, -angle);
|
||||
transform.updateMatrix();
|
||||
rays.current.setMatrixAt(index, transform.matrix);
|
||||
}
|
||||
rays.current.instanceMatrix.needsUpdate = true;
|
||||
}, [transform]);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.68, -0.04]}>
|
||||
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
|
||||
<boxGeometry args={[0.045, 0.16, 0.035]} />
|
||||
<RelicMaterial color={accentColor} />
|
||||
</instancedMesh>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.42, 0.04, 6, 20]} />
|
||||
<RelicMaterial color={accentColor} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0.015]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<RelicMaterial color={secondaryColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) {
|
||||
const animated = useRef<THREE.Group>(null);
|
||||
|
||||
@@ -25,18 +59,7 @@ export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile
|
||||
});
|
||||
|
||||
if (profile.accessory === "sun-halo") {
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.42, 0.045, 6, 18]} />
|
||||
<RelicMaterial color={profile.accentColor} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]} rotation={[0, 0, Math.PI / 4]}>
|
||||
<octahedronGeometry args={[0.11, 0]} />
|
||||
<RelicMaterial color={profile.secondaryColor} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
return <DawnNimbus accentColor={profile.accentColor} secondaryColor={profile.secondaryColor} />;
|
||||
}
|
||||
|
||||
if (profile.accessory === "grove-antlers") {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createPortal } from "@react-three/fiber";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
CHARACTER_PART_CATALOG,
|
||||
characterAppearancePartIds,
|
||||
type CharacterAppearanceV1,
|
||||
type CharacterMaterialVariant,
|
||||
type CharacterPartId,
|
||||
} from "../game/characterAppearance";
|
||||
import type { MemberId } from "../game/types";
|
||||
@@ -12,9 +14,14 @@ import { useGameGLTF } from "./GameAssetProvider";
|
||||
|
||||
interface BoundCharacterPart {
|
||||
group: THREE.Group;
|
||||
materials: THREE.Material[];
|
||||
skeletons: THREE.Skeleton[];
|
||||
}
|
||||
|
||||
const CHARACTER_MATERIAL_VARIANT_URLS: Record<CharacterMaterialVariant, string> = {
|
||||
"priest-vestments": new URL("../assets/game/textures/claudecraft/chars/players/priest-vestments.webp", import.meta.url).href,
|
||||
};
|
||||
|
||||
function rigBonesByName(rigScene: THREE.Object3D) {
|
||||
const bones = new Map<string, THREE.Bone>();
|
||||
rigScene.traverse((object) => {
|
||||
@@ -27,11 +34,13 @@ function createBoundCharacterPart(
|
||||
sourceScene: THREE.Object3D,
|
||||
rigScene: THREE.Object3D,
|
||||
partId: CharacterPartId,
|
||||
materialTexture?: THREE.Texture,
|
||||
): BoundCharacterPart {
|
||||
const definition = CHARACTER_PART_CATALOG[partId];
|
||||
const rigBones = rigBonesByName(rigScene);
|
||||
const group = new THREE.Group();
|
||||
group.name = `character-part:${partId}`;
|
||||
const materialClones = new Map<THREE.Material, THREE.Material>();
|
||||
const skeletons: THREE.Skeleton[] = [];
|
||||
|
||||
sourceScene.updateMatrixWorld(true);
|
||||
@@ -44,6 +53,20 @@ function createBoundCharacterPart(
|
||||
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
|
||||
partNode.traverse((object) => {
|
||||
if (!(object instanceof THREE.SkinnedMesh)) return;
|
||||
if (materialTexture) {
|
||||
const cloneMaterial = (source: THREE.Material) => {
|
||||
const existing = materialClones.get(source);
|
||||
if (existing) return existing;
|
||||
const clone = source.clone();
|
||||
if ("map" in clone) clone.map = materialTexture;
|
||||
clone.needsUpdate = true;
|
||||
materialClones.set(source, clone);
|
||||
return clone;
|
||||
};
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(cloneMaterial)
|
||||
: cloneMaterial(object.material);
|
||||
}
|
||||
const mappedBones = object.skeleton.bones.map((sourceBone) => {
|
||||
const rigBone = rigBones.get(sourceBone.name);
|
||||
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
|
||||
@@ -63,7 +86,56 @@ function createBoundCharacterPart(
|
||||
group.add(partNode);
|
||||
}
|
||||
|
||||
return { group, skeletons };
|
||||
return { group, materials: [...materialClones.values()], skeletons };
|
||||
}
|
||||
|
||||
function BoundCharacterPart({
|
||||
actorScene,
|
||||
materialTexture,
|
||||
modelUrls,
|
||||
partId,
|
||||
}: {
|
||||
actorScene: THREE.Object3D;
|
||||
materialTexture?: THREE.Texture;
|
||||
modelUrls: Record<MemberId, string>;
|
||||
partId: CharacterPartId;
|
||||
}) {
|
||||
const definition = CHARACTER_PART_CATALOG[partId];
|
||||
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
|
||||
const boundPart = useMemo(
|
||||
() => createBoundCharacterPart(gltf.scene, actorScene, partId, materialTexture),
|
||||
[actorScene, gltf.scene, materialTexture, partId],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const skeleton of boundPart.skeletons) skeleton.dispose();
|
||||
for (const material of boundPart.materials) material.dispose();
|
||||
}, [boundPart]);
|
||||
|
||||
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
|
||||
return createPortal(<primitive object={boundPart.group} />, rigRoot);
|
||||
}
|
||||
|
||||
function MaterialVariantCharacterPart({
|
||||
actorScene,
|
||||
materialVariant,
|
||||
modelUrls,
|
||||
partId,
|
||||
}: {
|
||||
actorScene: THREE.Object3D;
|
||||
materialVariant: CharacterMaterialVariant;
|
||||
modelUrls: Record<MemberId, string>;
|
||||
partId: CharacterPartId;
|
||||
}) {
|
||||
const sourceTexture = useTexture(CHARACTER_MATERIAL_VARIANT_URLS[materialVariant]);
|
||||
const materialTexture = useMemo(() => {
|
||||
sourceTexture.flipY = false;
|
||||
sourceTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
sourceTexture.magFilter = THREE.NearestFilter;
|
||||
sourceTexture.needsUpdate = true;
|
||||
return sourceTexture;
|
||||
}, [sourceTexture]);
|
||||
return <BoundCharacterPart actorScene={actorScene} materialTexture={materialTexture} modelUrls={modelUrls} partId={partId} />;
|
||||
}
|
||||
|
||||
function ModularCharacterPart({
|
||||
@@ -76,18 +148,10 @@ function ModularCharacterPart({
|
||||
partId: CharacterPartId;
|
||||
}) {
|
||||
const definition = CHARACTER_PART_CATALOG[partId];
|
||||
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
|
||||
const boundPart = useMemo(
|
||||
() => createBoundCharacterPart(gltf.scene, actorScene, partId),
|
||||
[actorScene, gltf.scene, partId],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const skeleton of boundPart.skeletons) skeleton.dispose();
|
||||
}, [boundPart]);
|
||||
|
||||
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
|
||||
return createPortal(<primitive object={boundPart.group} />, rigRoot);
|
||||
const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined;
|
||||
return materialVariant
|
||||
? <MaterialVariantCharacterPart actorScene={actorScene} materialVariant={materialVariant} modelUrls={modelUrls} partId={partId} />
|
||||
: <BoundCharacterPart actorScene={actorScene} modelUrls={modelUrls} partId={partId} />;
|
||||
}
|
||||
|
||||
export function ModularCharacterBody({
|
||||
|
||||
@@ -10,9 +10,38 @@ import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
|
||||
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
|
||||
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
|
||||
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
|
||||
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
|
||||
|
||||
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
|
||||
GameScene.displayName = "MemoizedGameScene";
|
||||
const CONVICTION_PIPS = [0, 1, 2] as const;
|
||||
const MAX_CONVICTION = healerMaxResource("paladin");
|
||||
|
||||
function ConvictionMeter() {
|
||||
const healerClassId = useGameStore((state) => state.healerClassId);
|
||||
const runMode = useGameStore((state) => state.runMode);
|
||||
const encounterConviction = useGameStore((state) => state.healerMechanic.resource);
|
||||
const rpgConviction = useGameStore((state) => state.rpgSpellResources.conviction);
|
||||
if (healerClassId !== "paladin") return null;
|
||||
const conviction = Math.max(0, Math.min(MAX_CONVICTION, runMode === "rpg-roguelike" ? rpgConviction : encounterConviction));
|
||||
return (
|
||||
<div
|
||||
className={`top-conviction-meter ${conviction === MAX_CONVICTION ? "is-full" : ""}`}
|
||||
role="meter"
|
||||
aria-label="Conviction"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={MAX_CONVICTION}
|
||||
aria-valuenow={conviction}
|
||||
aria-valuetext={`${conviction} of ${MAX_CONVICTION} Conviction`}
|
||||
title={`${conviction} of ${MAX_CONVICTION} Conviction`}
|
||||
>
|
||||
<span><b>Conviction</b><small>{conviction} / {MAX_CONVICTION}</small></span>
|
||||
<span className="top-conviction-pips" aria-hidden="true">
|
||||
{CONVICTION_PIPS.map((pip) => <i className={pip < conviction ? "is-filled" : ""} key={pip} />)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactParty() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
@@ -24,17 +53,19 @@ function CompactParty() {
|
||||
const selectMember = useGameStore((state) => state.selectMember);
|
||||
const mana = useGameStore((state) => state.mana);
|
||||
const maxMana = useGameStore((state) => state.maxMana);
|
||||
const healerMechanic = useGameStore((state) => state.healerMechanic);
|
||||
return (
|
||||
<div className="top-party" aria-label="Party health">
|
||||
<div className="top-party" aria-label="Party health and player resources">
|
||||
{party.map((member) => {
|
||||
const health = (member.hp / member.maxHp) * 100;
|
||||
const shield = (member.absorb / member.maxHp) * 100;
|
||||
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
|
||||
return (
|
||||
<button
|
||||
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
|
||||
className={`top-party-member ${selected === member.id ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""}`}
|
||||
key={member.id}
|
||||
onClick={() => selectMember(member.id)}
|
||||
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
|
||||
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
|
||||
>
|
||||
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
|
||||
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
|
||||
@@ -48,6 +79,7 @@ function CompactParty() {
|
||||
</span>
|
||||
)}
|
||||
<span className="top-effects">
|
||||
{beaconed && <em className="beacon-pip" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}>✦</em>}
|
||||
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
|
||||
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
|
||||
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
|
||||
@@ -59,6 +91,7 @@ function CompactParty() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<ConvictionMeter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
activePlayfieldKind,
|
||||
consumeSimulationSteps,
|
||||
outcomeElapsedAfterPhaseChange,
|
||||
resetSceneClockForMode,
|
||||
sceneCanvasFrameloop,
|
||||
sceneFrameIntervalMs,
|
||||
selectSceneRenderMode,
|
||||
startSceneFrameLoop,
|
||||
@@ -59,10 +61,22 @@ describe("scene render policy", () => {
|
||||
});
|
||||
|
||||
it("has no continuous interval for static or suspended scenes", () => {
|
||||
expect(sceneCanvasFrameloop("active")).toBe("demand");
|
||||
expect(sceneCanvasFrameloop("outcome")).toBe("demand");
|
||||
expect(sceneCanvasFrameloop("static")).toBe("demand");
|
||||
expect(sceneCanvasFrameloop("suspended")).toBe("never");
|
||||
expect(sceneFrameIntervalMs("static")).toBeNull();
|
||||
expect(sceneFrameIntervalMs("suspended")).toBeNull();
|
||||
});
|
||||
|
||||
it("resets the demand clock before active and outcome frame requests", () => {
|
||||
const start = vi.fn();
|
||||
for (const mode of ["static", "suspended", "active", "outcome"] as const) {
|
||||
resetSceneClockForMode({ start }, mode);
|
||||
}
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("mounts only the selected special playfield", () => {
|
||||
expect(activePlayfieldKind("boss")).toBeNull();
|
||||
expect(activePlayfieldKind("hockey-healing")).toBe("hockey-healing");
|
||||
@@ -82,7 +96,7 @@ describe("scene render policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("manual scene frame loop", () => {
|
||||
describe("scene frame request loop", () => {
|
||||
it("caps active rendering at 60 FPS", () => {
|
||||
const fake = createFakeFrames();
|
||||
const samples: SceneFrameSample[] = [];
|
||||
@@ -99,7 +113,7 @@ describe("manual scene frame loop", () => {
|
||||
expect(fake.runNext(25)).toBe(true);
|
||||
expect(fake.runNext(33.4)).toBe(true);
|
||||
expect(samples).toHaveLength(3);
|
||||
expect(samples.map((sample) => sample.manualTimeSeconds)).toEqual([0, 0.0167, 0.0334]);
|
||||
expect(samples.map((sample) => sample.elapsedSeconds)).toEqual([0, 0.0167, 0.0167]);
|
||||
|
||||
stop();
|
||||
expect(fake.pending()).toBe(0);
|
||||
@@ -146,7 +160,6 @@ describe("manual scene frame loop", () => {
|
||||
|
||||
expect(completed).toHaveBeenCalledTimes(1);
|
||||
expect(samples.at(-1)?.outcomeElapsedSeconds).toBe(7);
|
||||
expect(samples.at(-1)?.manualTimeSeconds).toBe(7);
|
||||
expect(fake.pending()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -167,7 +180,6 @@ describe("manual scene frame loop", () => {
|
||||
const afterResume: SceneFrameSample[] = [];
|
||||
const stopResumed = startSceneFrameLoop({
|
||||
mode: "active",
|
||||
initialManualTimeSeconds: beforeHide.at(-1)?.manualTimeSeconds,
|
||||
requestFrame: fake.requestFrame,
|
||||
cancelFrame: fake.cancelFrame,
|
||||
onFrame: (sample) => afterResume.push(sample),
|
||||
@@ -178,7 +190,6 @@ describe("manual scene frame loop", () => {
|
||||
expect(afterResume[0]).toMatchObject({
|
||||
elapsedMs: 0,
|
||||
elapsedSeconds: 0,
|
||||
manualTimeSeconds: beforeHide.at(-1)?.manualTimeSeconds,
|
||||
});
|
||||
stopResumed();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type * as THREE from "three";
|
||||
import type { GamePhase, GameplayActivity } from "../game/types";
|
||||
|
||||
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
|
||||
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
|
||||
|
||||
// Visible modes stay in R3F's demand clock domain. Suspended mode blocks loader
|
||||
// and host-commit invalidations as well as the explicit gameplay request loop.
|
||||
export function sceneCanvasFrameloop(mode: SceneRenderMode): "demand" | "never" {
|
||||
return mode === "suspended" ? "never" : "demand";
|
||||
}
|
||||
|
||||
export function resetSceneClockForMode(clock: Pick<THREE.Clock, "start">, mode: SceneRenderMode) {
|
||||
if (mode === "active" || mode === "outcome") clock.start();
|
||||
}
|
||||
|
||||
export const GAMEPLAY_FRAME_INTERVAL_MS = 1_000 / 60;
|
||||
export const OUTCOME_FRAME_INTERVAL_MS = 1_000 / 30;
|
||||
export const FRAME_INTERVAL_JITTER_MS = 1.5;
|
||||
@@ -76,13 +87,11 @@ export function consumeSimulationSteps(
|
||||
export interface SceneFrameSample {
|
||||
elapsedMs: number;
|
||||
elapsedSeconds: number;
|
||||
manualTimeSeconds: number;
|
||||
outcomeElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface SceneFrameLoopOptions {
|
||||
mode: "active" | "outcome";
|
||||
initialManualTimeSeconds?: number;
|
||||
initialOutcomeElapsedSeconds?: number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
@@ -91,12 +100,11 @@ export interface SceneFrameLoopOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the bounded manual R3F loop used by active gameplay and finite outcome
|
||||
* animation tails. Static and suspended modes intentionally have no manual loop.
|
||||
* Starts the bounded request loop used for active gameplay and finite outcome
|
||||
* animation tails. Static and suspended modes intentionally have no loop.
|
||||
*/
|
||||
export function startSceneFrameLoop({
|
||||
mode,
|
||||
initialManualTimeSeconds = 0,
|
||||
initialOutcomeElapsedSeconds = 0,
|
||||
requestFrame,
|
||||
cancelFrame,
|
||||
@@ -107,7 +115,6 @@ export function startSceneFrameLoop({
|
||||
let frameHandle: number | null = null;
|
||||
let stopped = false;
|
||||
let lastRenderedAt: number | null = null;
|
||||
let manualTimeSeconds = Math.max(0, initialManualTimeSeconds);
|
||||
let outcomeElapsedSeconds = mode === "outcome" ? Math.max(0, initialOutcomeElapsedSeconds) : 0;
|
||||
|
||||
const scheduleNext = () => {
|
||||
@@ -120,7 +127,7 @@ export function startSceneFrameLoop({
|
||||
const previous = lastRenderedAt;
|
||||
if (previous === null) {
|
||||
lastRenderedAt = now;
|
||||
onFrame({ elapsedMs: 0, elapsedSeconds: 0, manualTimeSeconds, outcomeElapsedSeconds });
|
||||
onFrame({ elapsedMs: 0, elapsedSeconds: 0, outcomeElapsedSeconds });
|
||||
} else {
|
||||
const elapsedMs = now - previous;
|
||||
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= intervalMs) {
|
||||
@@ -129,9 +136,8 @@ export function startSceneFrameLoop({
|
||||
const elapsedSeconds = mode === "active"
|
||||
? Math.min(MAX_FRAME_DELTA_SECONDS, wallElapsedSeconds)
|
||||
: wallElapsedSeconds;
|
||||
manualTimeSeconds += elapsedSeconds;
|
||||
if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds;
|
||||
onFrame({ elapsedMs, elapsedSeconds, manualTimeSeconds, outcomeElapsedSeconds });
|
||||
onFrame({ elapsedMs, elapsedSeconds, outcomeElapsedSeconds });
|
||||
|
||||
if (mode === "outcome" && outcomeElapsedSeconds >= OUTCOME_RENDER_TAIL_SECONDS) {
|
||||
stopped = true;
|
||||
|
||||
Reference in New Issue
Block a user