new help section explaining classes and their mechanics

This commit is contained in:
Warren H
2026-07-18 12:05:34 -04:00
parent 638aef22b2
commit 40aa23be46
32 changed files with 1423 additions and 134 deletions
+12 -7
View File
@@ -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
View File
@@ -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>0103</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 />;
+92 -19
View File
@@ -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 }}
+37 -14
View File
@@ -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") {
+77 -13
View File
@@ -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({
+36 -3
View File
@@ -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>
);
}
+16 -5
View File
@@ -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();
});
+15 -9
View File
@@ -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;