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
+1
View File
@@ -23,6 +23,7 @@
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs",
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { createGameAssetIO } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb");
const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp");
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function luminance(red, green, blue) {
return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
}
function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) {
const samples = [];
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]));
}
}
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const range = Math.max(0.001, maximum - minimum);
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
const mix = clamp01((sourceLuminance - minimum) / range);
for (let channel = 0; channel < 3; channel += 1) {
pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix);
}
}
}
}
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture");
if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture.");
const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const { width, height, channels } = decoded.info;
if (width !== 512 || height !== 512 || channels !== 4) {
throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`);
}
const pixels = new Uint8Array(decoded.data);
paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]);
paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } })
.webp({ quality: 92, smartSubsample: true })
.toBuffer());
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+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;
+3 -1
View File
@@ -45,7 +45,9 @@ describe("Appearance Lab frontend state", () => {
state.setAppearancePreviewAnimation("cast");
state = useFrontendStore.getState();
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("mage-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe(
createDefaultHealerAppearance("priest").headPartId,
);
expect(state.previewMode).toBe("legacy");
expect(state.previewAnimation).toBe("cast");
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { getFrontendSnapshot, useFrontendStore } from "./store";
const originalState = useFrontendStore.getState();
afterEach(() => {
useFrontendStore.setState({
activeSlotId: originalState.activeSlotId,
screen: originalState.screen,
guideClassId: originalState.guideClassId,
guideAbilityId: originalState.guideAbilityId,
notice: originalState.notice,
});
});
describe("Class Help frontend state", () => {
it("opens on ability one, resets selection on class change, and syncs guide state", () => {
useFrontendStore.setState({
activeSlotId: null,
screen: "home",
guideClassId: "shaman",
guideAbilityId: "ability6",
notice: "Old notice",
});
useFrontendStore.getState().openClassHelp();
let state = useFrontendStore.getState();
expect(state.screen).toBe("class-help");
expect(state.guideClassId).toBe("shaman");
expect(state.guideAbilityId).toBe("ability1");
expect(state.notice).toBe("");
state.selectGuideClass("paladin");
state.selectGuideAbility("ability5");
const snapshot = getFrontendSnapshot();
expect(snapshot.guideClassId).toBe("paladin");
expect(snapshot.guideAbilityId).toBe("ability5");
expect("openClassHelp" in snapshot).toBe(false);
expect("selectGuideClass" in snapshot).toBe(false);
expect("selectGuideAbility" in snapshot).toBe(false);
});
});
+24
View File
@@ -126,6 +126,8 @@ export interface FrontendState {
selectedInfusionId: string;
selectedPassiveAbilityId: AbilitySlotId;
selectedPassiveInfusionId: RunBuffId;
guideClassId: HealerClassId;
guideAbilityId: AbilitySlotId;
profileCollectionView: ProfileCollectionView;
selectedProfileGroupId: BossGroupId;
selectedProfileStatId: ProfileStatId;
@@ -158,6 +160,9 @@ export interface FrontendState {
selectInfusion: (infusionId: string) => void;
selectPassiveAbility: (abilityId: AbilitySlotId) => void;
selectPassiveInfusion: (passiveId: RunBuffId) => void;
openClassHelp: () => void;
selectGuideClass: (classId: HealerClassId) => void;
selectGuideAbility: (abilityId: AbilitySlotId) => void;
selectProfileCollectionView: (view: ProfileCollectionView) => void;
selectProfileGroup: (groupId: BossGroupId) => void;
selectProfileStat: (statId: ProfileStatId) => void;
@@ -207,6 +212,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "ability1",
selectedPassiveInfusionId: "mend-echo",
guideClassId: "priest",
guideAbilityId: "ability1",
profileCollectionView: "stats",
selectedProfileGroupId: "charge",
selectedProfileStatId: "roguelike",
@@ -373,6 +380,17 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedPassiveInfusionId,
notice: "",
}),
openClassHelp: () => {
const hunter = activeSave(get().slots, get().activeSlotId);
set({
screen: "class-help",
guideClassId: hunter?.activeClassId ?? get().guideClassId,
guideAbilityId: "ability1",
notice: "",
});
},
selectGuideClass: (guideClassId) => set({ guideClassId, guideAbilityId: "ability1" }),
selectGuideAbility: (guideAbilityId) => set({ guideAbilityId }),
selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }),
selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }),
selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }),
@@ -729,6 +747,9 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "openClassHelp"
| "selectGuideClass"
| "selectGuideAbility"
| "selectProfileCollectionView"
| "selectProfileGroup"
| "selectProfileStat"
@@ -783,6 +804,9 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
openClassHelp: _openClassHelp,
selectGuideClass: _selectGuideClass,
selectGuideAbility: _selectGuideAbility,
selectProfileCollectionView: _selectProfileCollectionView,
selectProfileGroup: _selectProfileGroup,
selectProfileStat: _selectProfileStat,
+1 -1
View File
@@ -5,7 +5,7 @@ import type { CollectionLog, MaterialStack } from "../game/progression/loot";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "appearance" | "settings" | "mode" | "game";
export type AppScreen = "login" | "saves" | "home" | "class-help" | "profile" | "gear" | "appearance" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault" | "roguelike-pvp" | "stadium-pvp";
export type ProfileCollectionView = "loot" | "trophies" | "stats";
export type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
+4
View File
@@ -38,6 +38,7 @@ export const APPEARANCE_SLOT_DEFINITIONS: readonly AppearanceSlotDefinition[] =
] as const;
const HEAD_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"head">>[] = [
{ value: "priest-head", label: "First Light" },
{ value: "druid-head", label: "Grove" },
{ value: "mage-head", label: "Mystic" },
{ value: "ranger-head", label: "Wayfinder" },
@@ -46,6 +47,7 @@ const HEAD_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"head">>[] = [
];
const UPPER_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"upper-body">>[] = [
{ value: "priest-upper", label: "First Light vestments" },
{ value: "druid-upper", label: "Grove leathers" },
{ value: "mage-upper", label: "Mystic robes" },
{ value: "ranger-upper", label: "Wayfinder mail" },
@@ -54,6 +56,7 @@ const UPPER_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"upper-body">>
];
const LOWER_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"lower-body">>[] = [
{ value: "priest-lower", label: "First Light boots" },
{ value: "druid-lower", label: "Grove boots" },
{ value: "mage-lower", label: "Mystic boots" },
{ value: "ranger-lower", label: "Wayfinder boots" },
@@ -69,6 +72,7 @@ const HEADWEAR_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"headwear">
const BACK_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"back"> | null>[] = [
{ value: null, label: "None" },
{ value: "priest-cape", label: "First Light mantle" },
{ value: "druid-backpack", label: "Grove pack" },
{ value: "mage-cape", label: "Mystic cape" },
{ value: "ranger-cape", label: "Wayfinder cape" },
+6
View File
@@ -22,26 +22,31 @@ export const CHARACTER_WEAPON_GRIP_BY_MODEL = Object.fromEntries(
) as Record<CharacterWeaponModelId, CharacterWeaponGrip>;
export type CharacterPartSlot = "head" | "upper-body" | "lower-body" | "headwear" | "back";
export type CharacterMaterialVariant = "priest-vestments";
export interface CharacterPartDefinition {
slot: CharacterPartSlot;
sourceMemberId: MemberId;
nodeNames: readonly string[];
materialVariant?: CharacterMaterialVariant;
}
export const CHARACTER_PART_CATALOG = {
"priest-head": { slot: "head", sourceMemberId: "orin", nodeNames: ["Mage_Head"] },
"druid-head": { slot: "head", sourceMemberId: "aelia", nodeNames: ["Druid_Head"] },
"mage-head": { slot: "head", sourceMemberId: "orin", nodeNames: ["Mage_Head"] },
"ranger-head": { slot: "head", sourceMemberId: "nia", nodeNames: ["Ranger_Head"] },
"knight-head": { slot: "head", sourceMemberId: "brann", nodeNames: ["Knight_Head"] },
"rogue-head": { slot: "head", sourceMemberId: "vale", nodeNames: ["Rogue_Head"] },
"priest-upper": { slot: "upper-body", sourceMemberId: "orin", nodeNames: ["Mage_ArmLeft", "Mage_ArmRight", "Mage_Body"], materialVariant: "priest-vestments" },
"druid-upper": { slot: "upper-body", sourceMemberId: "aelia", nodeNames: ["Druid_ArmLeft", "Druid_ArmRight", "Druid_Body"] },
"mage-upper": { slot: "upper-body", sourceMemberId: "orin", nodeNames: ["Mage_ArmLeft", "Mage_ArmRight", "Mage_Body"] },
"ranger-upper": { slot: "upper-body", sourceMemberId: "nia", nodeNames: ["Ranger_ArmLeft", "Ranger_ArmRight", "Ranger_Body"] },
"knight-upper": { slot: "upper-body", sourceMemberId: "brann", nodeNames: ["Knight_ArmLeft", "Knight_ArmRight", "Knight_Body"] },
"rogue-upper": { slot: "upper-body", sourceMemberId: "vale", nodeNames: ["Rogue_ArmLeft", "Rogue_ArmRight", "Rogue_Body"] },
"priest-lower": { slot: "lower-body", sourceMemberId: "orin", nodeNames: ["Mage_LegLeft", "Mage_LegRight"], materialVariant: "priest-vestments" },
"druid-lower": { slot: "lower-body", sourceMemberId: "aelia", nodeNames: ["Druid_LegLeft", "Druid_LegRight"] },
"mage-lower": { slot: "lower-body", sourceMemberId: "orin", nodeNames: ["Mage_LegLeft", "Mage_LegRight"] },
"ranger-lower": { slot: "lower-body", sourceMemberId: "nia", nodeNames: ["Ranger_LegLeft", "Ranger_LegRight"] },
@@ -51,6 +56,7 @@ export const CHARACTER_PART_CATALOG = {
"mage-hat": { slot: "headwear", sourceMemberId: "orin", nodeNames: ["Mage_Hat"] },
"knight-helmet": { slot: "headwear", sourceMemberId: "brann", nodeNames: ["Knight_Helmet", "Knight_HelmetVisor"] },
"priest-cape": { slot: "back", sourceMemberId: "orin", nodeNames: ["Mage_Cape"], materialVariant: "priest-vestments" },
"druid-backpack": { slot: "back", sourceMemberId: "aelia", nodeNames: ["Druid_Backpack"] },
"mage-cape": { slot: "back", sourceMemberId: "orin", nodeNames: ["Mage_Cape"] },
"ranger-cape": { slot: "back", sourceMemberId: "nia", nodeNames: ["Ranger_Cape"] },
+9 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { healingEffect } from "./healerEffects";
import { createClassInventory } from "./healers";
import { barrierProtects, useGameStore } from "./store";
import { BARRIER_RADIUS, barrierProtects, healerFieldContains, useGameStore } from "./store";
import type { HealerClassId, MemberId, WorldPosition } from "./types";
function startQuietEncounter(classId: HealerClassId) {
@@ -176,6 +176,14 @@ describe("Restoration Shaman combat kit", () => {
expect(member("brann").hp / member("brann").maxHp).toBeCloseTo(0.5);
expect(member("nia").hp / member("nia").maxHp).toBeCloseTo(0.5);
});
it("links allies within the enlarged 4m Spirit Link radius", () => {
useGameStore.getState().castAbility("ability6");
const field = useGameStore.getState().barrier;
expect(healerFieldContains([field.center[0] + BARRIER_RADIUS - 0.01, field.center[1]], field, 1)).toBe(true);
expect(healerFieldContains([field.center[0] + BARRIER_RADIUS + 0.01, field.center[1]], field, 1)).toBe(false);
});
});
describe("Dawnforged Paladin combat kit", () => {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { ABILITY_ORDER } from "./data";
import { HEALER_GUIDES } from "./healerGuides";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "./healers";
describe("healer class guides", () => {
it("covers every canonical class and ability", () => {
expect(Object.keys(HEALER_GUIDES)).toEqual(HEALER_CLASS_ORDER);
for (const classId of HEALER_CLASS_ORDER) {
const guide = HEALER_GUIDES[classId];
expect(Object.keys(guide.abilityGuides)).toEqual(ABILITY_ORDER);
expect(guide.coreLoop).toHaveLength(3);
for (const abilityId of ABILITY_ORDER) {
expect(guide.abilityGuides[abilityId].useWhen.length).toBeGreaterThan(20);
expect(guide.abilityGuides[abilityId].fieldTip.length).toBeGreaterThan(20);
expect(guide.abilityGuides[abilityId].synergies.length).toBeGreaterThan(0);
}
}
});
it("references valid companion abilities and labels direct interactions", () => {
for (const classId of HEALER_CLASS_ORDER) {
const guide = HEALER_GUIDES[classId];
let directInteractions = 0;
for (const abilityId of ABILITY_ORDER) {
for (const synergy of guide.abilityGuides[abilityId].synergies) {
expect(synergy.with).not.toBe(abilityId);
expect(HEALER_CLASSES[classId].abilities[synergy.with]).toBeDefined();
if (synergy.kind === "mechanic") directInteractions += 1;
}
}
if (classId !== "priest") expect(directInteractions).toBeGreaterThan(0);
}
});
});
+335
View File
@@ -0,0 +1,335 @@
import type { AbilitySlotId, HealerClassId } from "./types";
export type HealerSynergyKind = "mechanic" | "combo";
export interface HealerAbilitySynergy {
with: AbilitySlotId;
kind: HealerSynergyKind;
summary: string;
}
export interface HealerAbilityGuide {
useWhen: string;
fieldTip: string;
synergies: readonly HealerAbilitySynergy[];
}
export interface HealerClassGuide {
role: string;
learningCurve: "Approachable" | "Intermediate" | "Advanced";
resourceGuide: string;
coreLoop: readonly [string, string, string];
abilityGuides: Record<AbilitySlotId, HealerAbilityGuide>;
}
export const HEALER_GUIDES: Record<HealerClassId, HealerClassGuide> = {
priest: {
role: "Reactive all-rounder",
learningCurve: "Approachable",
resourceGuide: "Grace is your mana pool. Keep enough in reserve for Radiance or Barrier when party-wide damage is coming.",
coreLoop: [
"Maintain Renew on allies taking steady damage.",
"Use Aegis before a hit, then Mend whoever still drops.",
"Answer group pressure with Barrier before damage or Radiance after it.",
],
abilityGuides: {
ability1: {
useWhen: "One ally needs reliable healing now and you have time to finish the short cast.",
fieldTip: "Shield a critical target first when incoming damage could interrupt the recovery window.",
synergies: [
{ with: "ability3", kind: "combo", summary: "Aegis absorbs the next hit while Mend finishes its cast." },
{ with: "ability2", kind: "combo", summary: "Renew keeps recovery moving after Mend handles the immediate deficit." },
],
},
ability2: {
useWhen: "An ally will take sustained damage or needs gradual recovery between larger hits.",
fieldTip: "Apply early. Recasting on a healthy ally wastes mana that may be needed for burst healing.",
synergies: [
{ with: "ability3", kind: "combo", summary: "Aegis buys time for Renew's eight healing ticks to work." },
{ with: "ability1", kind: "combo", summary: "Mend covers the urgent gap while Renew completes the recovery." },
],
},
ability3: {
useWhen: "A selected ally is about to take a predictable heavy hit.",
fieldTip: "Absorption is strongest before damage lands. Avoid spending it after the danger has passed.",
synergies: [
{ with: "ability6", kind: "combo", summary: "Layer Aegis with Barrier for a protected target inside a safer party field." },
{ with: "ability1", kind: "combo", summary: "The shield protects the target while Mend restores missing health." },
],
},
ability4: {
useWhen: "A harmful magic effect appears and its mechanic calls for a dispel.",
fieldTip: "Do not cleanse automatically: some debuffs punish poor timing or leave a hazard where the ally stands.",
synergies: [
{ with: "ability3", kind: "combo", summary: "Aegis protects the ally from follow-up damage after the debuff is removed." },
{ with: "ability1", kind: "combo", summary: "Mend restores health already lost before the successful cleanse." },
],
},
ability5: {
useWhen: "Several party members are injured at the same time.",
fieldTip: "Wait for meaningful group damage; its 14-second cooldown makes light chip damage a poor trade.",
synergies: [
{ with: "ability6", kind: "combo", summary: "Barrier slows the next wave of damage while Radiance repairs the party." },
{ with: "ability2", kind: "combo", summary: "Renew can finish stabilizing the ally Radiance leaves lowest." },
],
},
ability6: {
useWhen: "The party can stack near you before a dangerous damage window.",
fieldTip: "Place the field before the hit. Allies outside its four-meter radius receive no reduction.",
synergies: [
{ with: "ability5", kind: "combo", summary: "Barrier reduces incoming group damage; Radiance repairs what gets through." },
{ with: "ability3", kind: "combo", summary: "Aegis adds focused protection for the ally most likely to be hit." },
],
},
},
},
druid: {
role: "Proactive healing-over-time specialist",
learningCurve: "Intermediate",
resourceGuide: "Druid healing ticks generate up to 5 Verdancy. Regrowth consumes up to 3 for bonus healing; a three-stack Lifebloom can spend 2 to bloom immediately.",
coreLoop: [
"Seed Rejuvenation and Lifebloom before pressure starts.",
"Spend Verdancy through Regrowth or an emergency Lifebloom bloom.",
"Layer several growths before Flourish, then use Nature's Cure for a targeted burst.",
],
abilityGuides: {
ability1: {
useWhen: "An ally needs direct healing but can also benefit from a six-second healing effect.",
fieldTip: "Build Verdancy first when possible; Regrowth automatically spends up to 3 for a much larger initial heal.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Rejuvenation ticks generate the Verdancy that powers Regrowth." },
{ with: "ability4", kind: "mechanic", summary: "Nature's Cure immediately triggers Regrowth's active healing tick." },
{ with: "ability6", kind: "mechanic", summary: "Flourish extends Regrowth and accelerates its healing ticks." },
],
},
ability2: {
useWhen: "An ally is likely to take damage soon or needs efficient sustained healing.",
fieldTip: "Spread it before group pressure so its ticks build Verdancy while healing is useful.",
synergies: [
{ with: "ability1", kind: "mechanic", summary: "Its ticks generate Verdancy for a stronger Regrowth." },
{ with: "ability4", kind: "mechanic", summary: "Nature's Cure triggers an immediate Rejuvenation tick on the cleansed target." },
{ with: "ability6", kind: "mechanic", summary: "Flourish extends Rejuvenation and doubles its tick rate during the window." },
],
},
ability3: {
useWhen: "A tank or focused ally will take repeated damage. Stack it up to 3 times.",
fieldTip: "At 3 stacks, recast with 2 Verdancy available to force the bloom instead of waiting for expiration.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Rejuvenation supplies Verdancy for Lifebloom's instant three-stack bloom." },
{ with: "ability4", kind: "mechanic", summary: "Nature's Cure triggers Lifebloom's current healing tick immediately." },
{ with: "ability6", kind: "mechanic", summary: "Flourish extends the stack and accelerates its periodic healing." },
],
},
ability4: {
useWhen: "A debuffed ally also has one or more active Druid healing effects.",
fieldTip: "More active growths mean a larger instant healing burst when the cleanse succeeds.",
synergies: [
{ with: "ability1", kind: "mechanic", summary: "Immediately triggers Regrowth's healing-over-time component." },
{ with: "ability2", kind: "mechanic", summary: "Immediately triggers Rejuvenation on the cleansed ally." },
{ with: "ability3", kind: "mechanic", summary: "Immediately triggers the active Lifebloom healing tick." },
],
},
ability5: {
useWhen: "Three allies are injured or group damage is about to continue for several seconds.",
fieldTip: "It automatically chooses the three most injured allies, so no target setup is required.",
synergies: [
{ with: "ability6", kind: "mechanic", summary: "Flourish extends Wild Growth and makes its party healing tick twice as fast." },
{ with: "ability1", kind: "mechanic", summary: "Wild Growth ticks build Verdancy for a stronger follow-up Regrowth." },
],
},
ability6: {
useWhen: "Several Druid healing effects are already active across the party.",
fieldTip: "Flourish does not create new effects. Layer growths first, then cast it during sustained damage.",
synergies: [
{ with: "ability5", kind: "mechanic", summary: "Wild Growth becomes a longer, faster group-healing window." },
{ with: "ability3", kind: "mechanic", summary: "Extends Lifebloom stacks and accelerates their healing ticks." },
{ with: "ability2", kind: "mechanic", summary: "Extended rapid Rejuvenation ticks also generate Verdancy faster." },
],
},
},
},
shaman: {
role: "Position-aware reactive burst healer",
learningCurve: "Intermediate",
resourceGuide: "Riptide, Earth Shield triggers, and successful cleanses generate up to 2 Tidal Surge. Healing Wave spends 1; Chain Heal spends all available Surge for extra jumps.",
coreLoop: [
"Keep Earth Shield on the ally taking repeated hits.",
"Use Riptide to create a Chain Heal anchor and build Tidal Surge.",
"Spend Surge on a fast Healing Wave or a longer Chain Heal when allies are grouped.",
],
abilityGuides: {
ability1: {
useWhen: "One ally is badly hurt, especially below half health.",
fieldTip: "Hold 1 Tidal Surge for emergencies: it halves the cast time and adds 10 healing.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Riptide grants the Tidal Surge that accelerates and strengthens Healing Wave." },
{ with: "ability3", kind: "mechanic", summary: "Earth Shield triggers grant Tidal Surge for the next Healing Wave." },
{ with: "ability4", kind: "mechanic", summary: "A successful Cleanse Spirit supplies Tidal Surge for the follow-up heal." },
],
},
ability2: {
useWhen: "An ally needs an instant top-up plus sustained healing, or should anchor Chain Heal.",
fieldTip: "Place it on the ally where you want Chain Heal's strongest first hit to land.",
synergies: [
{ with: "ability5", kind: "mechanic", summary: "Riptide strengthens Chain Heal's first heal by 25% and marks its starting anchor." },
{ with: "ability1", kind: "mechanic", summary: "The granted Tidal Surge makes Healing Wave faster and stronger." },
],
},
ability3: {
useWhen: "An ally will take frequent damage over the next 30 seconds.",
fieldTip: "Its six charges cannot help if placed on someone who is not being attacked.",
synergies: [
{ with: "ability1", kind: "mechanic", summary: "Damage-triggered Earth Shield heals generate Tidal Surge for Healing Wave." },
{ with: "ability5", kind: "mechanic", summary: "Generated Tidal Surge can be saved to add targets to Chain Heal." },
],
},
ability4: {
useWhen: "A harmful magic effect must be removed and its encounter timing is safe.",
fieldTip: "A successful cleanse grants Tidal Surge, so plan the next cast before using it.",
synergies: [
{ with: "ability1", kind: "mechanic", summary: "Spend the new Tidal Surge on an accelerated Healing Wave." },
{ with: "ability5", kind: "mechanic", summary: "Bank the new Tidal Surge to extend Chain Heal by one target." },
],
},
ability5: {
useWhen: "Several injured allies are close enough for the heal to jump between them.",
fieldTip: "It jumps to nearby injured allies. Spread formations can end the chain early even with Tidal Surge.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Start on a Riptide target for a 25% stronger first heal." },
{ with: "ability3", kind: "mechanic", summary: "Tidal Surge generated by Earth Shield adds one jump per stored charge." },
{ with: "ability6", kind: "combo", summary: "After Spirit Link equalizes the group, Chain Heal restores the shared deficit." },
],
},
ability6: {
useWhen: "Stacked allies have uneven health percentages during heavy pressure.",
fieldTip: "The field redistributes health; it does not create healing. Follow it with an actual heal.",
synergies: [
{ with: "ability5", kind: "combo", summary: "Chain Heal restores the group after Spirit Link redistributes the danger." },
{ with: "ability3", kind: "combo", summary: "Earth Shield keeps healing the focused ally while health is shared." },
],
},
},
},
paladin: {
role: "Offensive single-target healer",
learningCurve: "Intermediate",
resourceGuide: "Crusader Strike builds up to 3 Conviction. Word of Glory spends all of it; at 3 Conviction the cast also heals the whole party.",
coreLoop: [
"Keep Beacon of Light on the ally who needs steady indirect healing.",
"Use Crusader Strike on cooldown when safe to build Conviction.",
"Spend Conviction with Word of Glory, ideally at 3 during group damage.",
],
abilityGuides: {
ability1: {
useWhen: "One ally needs a strong direct heal and you can complete the short cast.",
fieldTip: "Heal someone other than your Beacon target to recover two allies from one cast.",
synergies: [
{ with: "ability3", kind: "mechanic", summary: "Holy Light on another ally echoes 40% of effective healing to the Beacon." },
{ with: "ability5", kind: "combo", summary: "Use Holy Light between Conviction spends for steady spot healing." },
],
},
ability2: {
useWhen: "The boss is reachable and the party can benefit from free smart healing.",
fieldTip: "Keep using it when safe; every cast advances your next Word of Glory.",
synergies: [
{ with: "ability5", kind: "mechanic", summary: "Each Crusader Strike adds 1 Conviction to Word of Glory." },
{ with: "ability6", kind: "combo", summary: "Crusader Strike keeps offense and smart healing active during Avenging Crusader." },
],
},
ability3: {
useWhen: "One ally, usually the tank, will need steady healing for the next 30 seconds.",
fieldTip: "Direct heals cast on the Beacon itself do not echo; heal other allies to trigger it.",
synergies: [
{ with: "ability1", kind: "mechanic", summary: "Holy Light on another ally echoes 40% of its effective healing to the Beacon." },
{ with: "ability5", kind: "mechanic", summary: "Word of Glory on another ally also echoes healing to the Beacon." },
{ with: "ability4", kind: "mechanic", summary: "Cleanse Light on another ally echoes its 10-point heal to the Beacon." },
],
},
ability4: {
useWhen: "A harmful magic effect must be dispelled and the target also needs a small heal.",
fieldTip: "Cleanse another ally while Beacon is active to gain value on both targets.",
synergies: [
{ with: "ability3", kind: "mechanic", summary: "Its 10-point heal echoes to a different Beacon target." },
{ with: "ability1", kind: "combo", summary: "Follow with Holy Light if the cleansed ally remains in danger." },
],
},
ability5: {
useWhen: "An ally needs an instant heal or the party needs the bonus from spending 3 Conviction.",
fieldTip: "It spends every Conviction point. Wait for 3 when safe, but spend early to prevent a death.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Crusader Strike generates the Conviction that scales Word of Glory." },
{ with: "ability3", kind: "mechanic", summary: "Casting on someone else echoes 40% of effective healing to the Beacon." },
],
},
ability6: {
useWhen: "The party is dealing sustained damage while allies need steady smart healing.",
fieldTip: "Use during an offensive burst window; 20% of party attack damage heals the most injured living ally.",
synergies: [
{ with: "ability2", kind: "combo", summary: "Crusader Strike maintains your damage-and-healing rhythm during the window." },
{ with: "ability5", kind: "combo", summary: "A full Conviction Word of Glory covers group damage while offense supplies smart heals." },
],
},
},
},
chronomancer: {
role: "Predictive timeline healer",
learningCurve: "Advanced",
resourceGuide: "A successful Time Anchor rewind and Erase Affliction each grant 1 of 3 Chronoshards. Accelerate spends all shards for stronger party healing and cooldown reduction.",
coreLoop: [
"Anchor a healthy ally before predictable damage, then recast to rewind it.",
"Schedule Echo of Tomorrow so its delayed heal lands after damage.",
"Bank Chronoshards for an empowered Accelerate and use Time Loop before major group hits.",
],
abilityGuides: {
ability1: {
useWhen: "One ally needs dependable immediate recovery and no timeline setup is available.",
fieldTip: "Use it to stabilize between planned Anchor rewinds and delayed Echo healing.",
synergies: [
{ with: "ability2", kind: "combo", summary: "Mend Timeline covers health lost before or after the Time Anchor window." },
{ with: "ability3", kind: "combo", summary: "Mend handles the immediate deficit while Echo schedules the follow-up." },
],
},
ability2: {
useWhen: "A healthy ally is about to take predictable damage within 6 seconds.",
fieldTip: "Recast on the same ally before the anchor expires. No restored damage means no Chronoshard.",
synergies: [
{ with: "ability5", kind: "mechanic", summary: "A successful rewind grants the Chronoshard that powers Accelerate." },
{ with: "ability3", kind: "combo", summary: "Echo can land after the rewind to continue stabilizing the target." },
],
},
ability3: {
useWhen: "An ally needs a small heal now and is likely to need a larger heal 3 seconds later.",
fieldTip: "Cast before a telegraphed hit so the 28-point echo lands after the damage instead of overhealing early.",
synergies: [
{ with: "ability6", kind: "combo", summary: "Time Loop reverses burst damage while Echo smooths the health window before restoration." },
{ with: "ability5", kind: "combo", summary: "Accelerate supplies immediate party healing while Echo resolves on its target later." },
],
},
ability4: {
useWhen: "A harmful magic effect must be removed and its encounter timing is safe.",
fieldTip: "Plan to spend the gained Chronoshard; holding at the cap wastes later generation.",
synergies: [
{ with: "ability5", kind: "mechanic", summary: "Erase Affliction grants 1 Chronoshard for stronger healing and cooldown reduction." },
{ with: "ability2", kind: "combo", summary: "Anchor first if the debuff will deal damage before the correct cleanse moment." },
],
},
ability5: {
useWhen: "The party is injured and you have Chronoshards or important active cooldowns to advance.",
fieldTip: "At 3 shards it heals the party for 34 and removes 3 seconds from every active cooldown.",
synergies: [
{ with: "ability2", kind: "mechanic", summary: "Successful Time Anchor rewinds generate Chronoshards for Accelerate." },
{ with: "ability4", kind: "mechanic", summary: "Successful cleanses generate Chronoshards for Accelerate." },
{ with: "ability6", kind: "mechanic", summary: "Shard-powered cooldown reduction can bring Time Loop back sooner." },
],
},
ability6: {
useWhen: "Major party damage will land during the next 6 seconds.",
fieldTip: "Cast before damage. It restores lost health to the recorded value but cannot resurrect dead allies.",
synergies: [
{ with: "ability3", kind: "combo", summary: "Echo of Tomorrow stabilizes an ally during the six-second loop window." },
{ with: "ability5", kind: "mechanic", summary: "Accelerate's cooldown reduction helps recover this long defensive cooldown." },
],
},
},
},
};
+12
View File
@@ -3,12 +3,24 @@ import { freshParty } from "./data";
import {
createHealerMechanicState,
healTargetAndBeacon,
isBeaconOfLightTarget,
placeOrRewindTimeAnchor,
resolveTimeLoop,
startTimeLoop,
} from "./healerMechanics";
describe("shared healer mechanics", () => {
it("identifies only the active Beacon of Light target", () => {
const mechanic = {
...createHealerMechanicState("paladin"),
beaconTargetId: "nia" as const,
beaconExpiresAt: 10,
};
expect(isBeaconOfLightTarget("nia", mechanic, 9.99)).toBe(true);
expect(isBeaconOfLightTarget("brann", mechanic, 9.99)).toBe(false);
expect(isBeaconOfLightTarget("nia", mechanic, 10)).toBe(false);
});
it("echoes a direct heal without healing dead or duplicate beacon targets", () => {
const party = freshParty("paladin").map((member) => member.id === "brann"
? { ...member, hp: 50 }
+13 -2
View File
@@ -40,6 +40,14 @@ export function createHealerMechanicState(classId: HealerClassId): HealerMechani
};
}
export function isBeaconOfLightTarget(
memberId: MemberId,
mechanic: HealerMechanicState,
time: number,
): boolean {
return mechanic.beaconTargetId === memberId && mechanic.beaconExpiresAt > time;
}
function healLiving(member: PartyMember, amount: number): PartyMember {
if (member.hp <= 0 || amount <= 0) return member;
return { ...member, hp: Math.min(member.maxHp, member.hp + amount) };
@@ -61,8 +69,11 @@ export function healTargetAndBeacon(
next[targetIndex] = healedTarget;
let beaconHealing = 0;
if (mechanic.beaconTargetId && mechanic.beaconExpiresAt > time && mechanic.beaconTargetId !== target.id) {
const beaconIndex = next.findIndex((member) => member.id === mechanic.beaconTargetId);
const beaconTargetId = mechanic.beaconTargetId;
if (beaconTargetId
&& isBeaconOfLightTarget(beaconTargetId, mechanic, time)
&& beaconTargetId !== target.id) {
const beaconIndex = next.findIndex((member) => member.id === beaconTargetId);
const beacon = next[beaconIndex];
if (beacon?.hp > 0) {
const healedBeacon = healLiving(beacon, directHealing * echoFraction);
+49
View File
@@ -33,4 +33,53 @@ describe("healer visual profiles", () => {
HEALER_VISUAL_PROFILES.druid.appearance,
);
});
it("upgrades the former mage-only priest default without replacing customized looks", () => {
const legacyDefault = {
version: 1,
rigId: "medium",
scaleSourceMemberId: "orin",
headPartId: "mage-head",
upperBodyPartId: "mage-upper",
lowerBodyPartId: "mage-lower",
headwearPartId: null,
backPartId: "mage-cape",
mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" },
};
expect(normalizeHealerAppearance("priest", legacyDefault)).toEqual(
HEALER_VISUAL_PROFILES.priest.appearance,
);
expect(normalizeHealerAppearance("priest", { ...legacyDefault, backPartId: null })).toMatchObject({
headPartId: "mage-head",
upperBodyPartId: "mage-upper",
lowerBodyPartId: "mage-lower",
backPartId: null,
});
});
it("upgrades the sword-bearing Paladin default to mace and shield", () => {
const legacyDefault = {
version: 1,
rigId: "medium",
scaleSourceMemberId: "brann",
headPartId: "knight-head",
upperBodyPartId: "knight-upper",
lowerBodyPartId: "knight-lower",
headwearPartId: "knight-helmet",
backPartId: "knight-cape",
mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" },
offHand: { modelId: "cc/shield_badge", grip: "prop" },
};
expect(normalizeHealerAppearance("paladin", legacyDefault)).toEqual(
HEALER_VISUAL_PROFILES.paladin.appearance,
);
expect(HEALER_VISUAL_PROFILES.paladin.appearance).toMatchObject({
mainHand: { modelId: "cc/hammer_a", grip: "upright" },
offHand: { modelId: "cc/shield_badge", grip: "prop" },
});
expect(normalizeHealerAppearance("paladin", { ...legacyDefault, backPartId: null })).toMatchObject({
backPartId: null,
mainHand: { modelId: "cc/adv_sword_1handed" },
});
});
});
+50 -7
View File
@@ -34,17 +34,17 @@ export const HEALER_VISUAL_PROFILES: Record<HealerClassId, HealerVisualProfile>
version: 1,
rigId: "medium",
scaleSourceMemberId: "orin",
headPartId: "mage-head",
upperBodyPartId: "mage-upper",
lowerBodyPartId: "mage-lower",
headPartId: "priest-head",
upperBodyPartId: "priest-upper",
lowerBodyPartId: "priest-lower",
headwearPartId: null,
backPartId: "mage-cape",
backPartId: "priest-cape",
mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" },
},
hiddenNodes: ["Mage_Hat"],
accessory: "sun-halo",
accentColor: "#ffe7a3",
secondaryColor: "#9a76ff",
secondaryColor: "#8bd8ff",
},
druid: {
bodyMemberId: "aelia",
@@ -96,7 +96,7 @@ export const HEALER_VISUAL_PROFILES: Record<HealerClassId, HealerVisualProfile>
lowerBodyPartId: "knight-lower",
headwearPartId: "knight-helmet",
backPartId: "knight-cape",
mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" },
mainHand: { modelId: "cc/hammer_a", grip: "upright" },
offHand: { modelId: "cc/shield_badge", grip: "prop" },
},
hiddenNodes: [],
@@ -126,12 +126,55 @@ export const HEALER_VISUAL_PROFILES: Record<HealerClassId, HealerVisualProfile>
},
};
const LEGACY_HEALER_DEFAULTS: Partial<Record<HealerClassId, CharacterAppearanceV1>> = {
priest: {
version: 1,
rigId: "medium",
scaleSourceMemberId: "orin",
headPartId: "mage-head",
upperBodyPartId: "mage-upper",
lowerBodyPartId: "mage-lower",
headwearPartId: null,
backPartId: "mage-cape",
mainHand: { modelId: "cc/adv_druid_staff", grip: "staff" },
},
paladin: {
version: 1,
rigId: "medium",
scaleSourceMemberId: "brann",
headPartId: "knight-head",
upperBodyPartId: "knight-upper",
lowerBodyPartId: "knight-lower",
headwearPartId: "knight-helmet",
backPartId: "knight-cape",
mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" },
offHand: { modelId: "cc/shield_badge", grip: "prop" },
},
};
function appearancesMatch(left: CharacterAppearanceV1, right: CharacterAppearanceV1) {
return left.version === right.version
&& left.rigId === right.rigId
&& left.scaleSourceMemberId === right.scaleSourceMemberId
&& left.headPartId === right.headPartId
&& left.upperBodyPartId === right.upperBodyPartId
&& left.lowerBodyPartId === right.lowerBodyPartId
&& left.headwearPartId === right.headwearPartId
&& left.backPartId === right.backPartId
&& left.mainHand.modelId === right.mainHand.modelId
&& left.offHand?.modelId === right.offHand?.modelId;
}
export function createDefaultHealerAppearance(classId: HealerClassId): CharacterAppearanceV1 {
return cloneCharacterAppearance(HEALER_VISUAL_PROFILES[classId].appearance);
}
export function normalizeHealerAppearance(classId: HealerClassId, value: unknown): CharacterAppearanceV1 {
return normalizeCharacterAppearance(value, HEALER_VISUAL_PROFILES[classId].appearance);
const normalized = normalizeCharacterAppearance(value, HEALER_VISUAL_PROFILES[classId].appearance);
const legacyDefault = LEGACY_HEALER_DEFAULTS[classId];
return legacyDefault && appearancesMatch(normalized, legacyDefault)
? createDefaultHealerAppearance(classId)
: normalized;
}
export function healerVisualSignature(profile: HealerVisualProfile) {
+2 -2
View File
@@ -50,7 +50,7 @@ export const HEALER_CLASSES: Record<HealerClassId, HealerClassDefinition> = {
ability3: ability("ability3", "priest-aegis-shield", "protective", "ally", { name: "Aegis Shield", shortName: "Shield", cooldown: 10, mana: 8, icon: "◇", description: "Give selected ally a 36-point damage shield.", color: "#6fc6ff" }),
ability4: ability("ability4", "priest-purify", "cleanse", "ally", { name: "Purify", shortName: "Purify", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from the selected ally.", color: "#b58cff" }),
ability5: ability("ability5", "priest-radiance", "group-heal", "party", { name: "Radiance", shortName: "Radiance", cooldown: 14, mana: 12, icon: "☀", description: "Heal every party member for 22 health.", color: "#ffd66b" }),
ability6: ability("ability6", "priest-barrier", "field", "party", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 3m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }),
ability6: ability("ability6", "priest-barrier", "field", "party", { name: "Barrier", shortName: "Barrier", cooldown: 60, mana: 10, icon: "◉", description: "Place a 4m field at your feet for 8 seconds. Allies inside take 30% less damage.", color: "#f2cf55" }),
},
},
druid: {
@@ -86,7 +86,7 @@ export const HEALER_CLASSES: Record<HealerClassId, HealerClassDefinition> = {
ability3: ability("ability3", "shaman-earth-shield", "protective", "ally", { name: "Earth Shield", shortName: "Earth Shield", cooldown: 10, mana: 8, icon: "⬡", description: "Give an ally 6 charges for 30 seconds. Taking damage consumes a charge to heal for 9 and grants Tidal Surge.", color: "#d2b66c" }),
ability4: ability("ability4", "shaman-cleanse-spirit", "cleanse", "ally", { name: "Cleanse Spirit", shortName: "Cleanse", cooldown: 3, mana: 5, icon: "✧", description: "Dispel all harmful magic from an ally. A successful cleanse grants Tidal Surge.", color: "#9aaef5" }),
ability5: ability("ability5", "shaman-chain-heal", "group-heal", "ally", { name: "Chain Heal", shortName: "Chain Heal", cooldown: 12, mana: 12, icon: "⌁", description: "Heal the target, then jump through nearby injured allies with diminishing power. Tidal Surge adds jumps; Riptide strengthens the first heal.", color: "#6ee2db" }),
ability6: ability("ability6", "shaman-spirit-link", "field", "party", { name: "Spirit Link Totem", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Place an 8-second, 3m spirit field that equalizes nearby allies' health percentages each second.", color: "#9d8cf2" }),
ability6: ability("ability6", "shaman-spirit-link", "field", "party", { name: "Spirit Link Totem", shortName: "Spirit Link", cooldown: 60, mana: 10, icon: "◎", description: "Place an 8-second, 4m spirit field that equalizes nearby allies' health percentages each second.", color: "#9d8cf2" }),
},
},
paladin: {
+9 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BULL_CHARGE } from "./bossMechanics";
import { distance, pointToSegmentDistance } from "./geometry";
import { RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { BARRIER_RADIUS, RUN_BUFF_INPUT_LOCK_MS, barrierProtects, useGameStore } from "./store";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { healingEffect } from "./healerEffects";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
@@ -168,6 +168,14 @@ describe("Disc Priest combat simulation", () => {
expect(barrierProtects(barrier.center, barrier, 8)).toBe(false);
});
it("protects allies within the enlarged 4m Barrier radius", () => {
useGameStore.getState().castAbility("ability6");
const barrier = useGameStore.getState().barrier;
expect(barrierProtects([barrier.center[0] + BARRIER_RADIUS - 0.01, barrier.center[1]], barrier, 1)).toBe(true);
expect(barrierProtects([barrier.center[0] + BARRIER_RADIUS + 0.01, barrier.center[1]], barrier, 1)).toBe(false);
});
it("keeps ranged allies stable while Vale holds behind the boss", () => {
const start = structuredClone(useGameStore.getState().partyPositions);
const bossPosition = useGameStore.getState().bossMotion.position;
+3 -3
View File
@@ -267,7 +267,7 @@ const emptyCooldowns = (): Record<AbilitySlotId, number> => ({
export const GLOBAL_COOLDOWN_SECONDS = 0.5;
export const RUN_BUFF_INPUT_LOCK_MS = 2_500;
export const BARRIER_RADIUS = 3;
export const BARRIER_RADIUS = 4;
export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
@@ -1324,7 +1324,7 @@ export const useGameStore = create<GameState>((set, get) => ({
expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
nextHealAt: state.time + 1,
};
message = `${ability.name} protects a 3m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
message = `${ability.name} protects a ${BARRIER_RADIUS}m circle for ${8 + state.runModifiers.barrierDurationBonus} seconds.`;
break;
case "druid-rejuvenation":
applyRejuvenationAt(party, selectedIndex, state.time, state.runModifiers, spellPower);
@@ -1422,7 +1422,7 @@ export const useGameStore = create<GameState>((set, get) => ({
expiresAt: state.time + 8 + state.runModifiers.barrierDurationBonus,
nextHealAt: state.time + 1,
};
message = `${ability.name} links allies inside a 3m circle.`;
message = `${ability.name} links allies inside a ${BARRIER_RADIUS}m circle.`;
break;
case "paladin-crusader-strike": {
const requestedDamage = 18 * spellPower;
+1 -1
View File
@@ -69,7 +69,7 @@ export const CLAUDECRAFT_WEAPON_CATALOG = [
{ id: "cc/halberd", label: "Halberd", category: "halberd", allowedSlots: ["main"], grip: "polearm", sourceFile: "halberd.glb", optimizedFile: null },
{ id: "cc/hammer_a", label: "Hammer A", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_a.glb", optimizedFile: null },
{ id: "cc/hammer_a", label: "Sunward Mace", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_a.glb", optimizedFile: null },
{ id: "cc/hammer_b", label: "Hammer B", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_b.glb", optimizedFile: null },
{ id: "cc/hammer_c", label: "Hammer C", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_c.glb", optimizedFile: null },
{ id: "cc/hammer_d", label: "Hammer D", category: "hammer", allowedSlots: ["main"], grip: "upright", sourceFile: "hammer_d.glb", optimizedFile: null },
+4
View File
@@ -20,6 +20,7 @@ function screenTitle(screen: AppScreen) {
case "login": return "Sign in or continue offline";
case "saves": return "Choose hunter save";
case "home": return "Choose expedition";
case "class-help": return "Class field guide";
case "profile": return "Hunter profile";
case "gear": return "Gear upgrade";
case "appearance": return "Appearance Lab";
@@ -134,6 +135,9 @@ export function BottomDisplayApp() {
selectInfusion: (infusionId) => postFrontend({ name: "selectInfusion", infusionId }),
selectPassiveAbility: (abilityId) => postFrontend({ name: "selectPassiveAbility", abilityId }),
selectPassiveInfusion: (passiveId) => postFrontend({ name: "selectPassiveInfusion", passiveId }),
openClassHelp: () => postFrontend({ name: "openClassHelp" }),
selectGuideClass: (classId) => postFrontend({ name: "selectGuideClass", classId }),
selectGuideAbility: (abilityId) => postFrontend({ name: "selectGuideAbility", abilityId }),
selectProfileCollectionView: (view) => postFrontend({ name: "selectProfileCollectionView", view }),
selectProfileGroup: (groupId) => postFrontend({ name: "selectProfileGroup", groupId }),
selectProfileStat: (statId) => postFrontend({ name: "selectProfileStat", statId }),
+22
View File
@@ -232,4 +232,26 @@ describe("dual-screen game snapshots", () => {
setAppearancePreviewAnimation: original.setAppearancePreviewAnimation,
});
});
it("routes Class Help selection through the authoritative frontend store", () => {
const original = useFrontendStore.getState();
const calls: string[] = [];
useFrontendStore.setState({
openClassHelp: () => { calls.push("open"); },
selectGuideClass: (classId) => { calls.push(`class:${classId}`); },
selectGuideAbility: (abilityId) => { calls.push(`ability:${abilityId}`); },
});
executeFrontendCommand({ name: "openClassHelp" });
executeFrontendCommand({ name: "selectGuideClass", classId: "shaman" });
executeFrontendCommand({ name: "selectGuideAbility", abilityId: "ability5" });
expect(calls).toEqual(["open", "class:shaman", "ability:ability5"]);
useFrontendStore.setState({
openClassHelp: original.openClassHelp,
selectGuideClass: original.selectGuideClass,
selectGuideAbility: original.selectGuideAbility,
});
});
});
+6
View File
@@ -57,6 +57,9 @@ export type FrontendCommand =
| { name: "selectInfusion"; infusionId: string }
| { name: "selectPassiveAbility"; abilityId: AbilitySlotId }
| { name: "selectPassiveInfusion"; passiveId: RunBuffId }
| { name: "openClassHelp" }
| { name: "selectGuideClass"; classId: HealerClassId }
| { name: "selectGuideAbility"; abilityId: AbilitySlotId }
| { name: "selectProfileCollectionView"; view: ProfileCollectionView }
| { name: "selectProfileGroup"; groupId: BossGroupId }
| { name: "selectProfileStat"; statId: ProfileStatId }
@@ -142,6 +145,9 @@ export function executeFrontendCommand(command: FrontendCommand) {
case "selectInfusion": frontend.selectInfusion(command.infusionId); break;
case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break;
case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break;
case "openClassHelp": frontend.openClassHelp(); break;
case "selectGuideClass": frontend.selectGuideClass(command.classId); break;
case "selectGuideAbility": frontend.selectGuideAbility(command.abilityId); break;
case "selectProfileCollectionView": frontend.selectProfileCollectionView(command.view); break;
case "selectProfileGroup": frontend.selectProfileGroup(command.groupId); break;
case "selectProfileStat": frontend.selectProfileStat(command.statId); break;
+266 -14
View File
@@ -232,6 +232,67 @@ button:focus-visible {
pointer-events: auto;
}
.top-conviction-meter {
display: grid;
gap: 5px;
padding: 6px 8px 7px;
border: 1px solid rgba(242, 166, 90, 0.34);
border-left: 2px solid #d98b43;
border-radius: 3px;
background: linear-gradient(90deg, rgba(34, 20, 8, 0.92), rgba(14, 17, 12, 0.76));
box-shadow: inset 12px 0 20px rgba(242, 166, 90, 0.08);
pointer-events: none;
text-shadow: 0 1px 3px #000;
}
.top-conviction-meter > span:first-child {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.top-conviction-meter b {
color: #f3bd79;
font-family: "Cinzel", serif;
font-size: 8px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.top-conviction-meter small {
color: #fff0bd;
font-size: 9px;
font-weight: 700;
white-space: nowrap;
}
.top-conviction-pips {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.top-conviction-pips i {
height: 5px;
border: 1px solid rgba(232, 188, 116, 0.24);
border-radius: 1px;
background: rgba(46, 31, 15, 0.85);
transform: skewX(-12deg);
}
.top-conviction-pips i.is-filled {
border-color: #ffd37a;
background: linear-gradient(90deg, #dc8737, #ffd77d);
box-shadow: 0 0 7px rgba(255, 190, 84, 0.7);
}
.top-conviction-meter.is-full {
border-color: rgba(255, 216, 114, 0.72);
border-left-color: #ffe091;
box-shadow: inset 12px 0 22px rgba(255, 186, 71, 0.13), 0 0 10px rgba(255, 196, 88, 0.16);
}
.top-party-member {
position: relative;
min-height: 42px;
@@ -258,6 +319,17 @@ button:focus-visible {
transform: translateX(3px);
}
.top-party-member.is-beacon {
border-color: rgba(255, 218, 105, 0.7);
border-right-color: #ffdf79;
background: linear-gradient(90deg, rgba(46, 39, 13, 0.94), rgba(18, 23, 13, 0.78));
box-shadow: inset -10px 0 18px rgba(255, 218, 92, 0.12), 0 0 10px rgba(255, 216, 89, 0.2);
}
.top-party-member.is-beacon .portrait-dot {
box-shadow: 0 0 9px rgba(255, 228, 132, 0.72), inset 0 0 8px rgba(255, 255, 255, 0.2);
}
.portrait-dot {
width: 29px;
height: 29px;
@@ -354,6 +426,7 @@ button:focus-visible {
}
.renew-pip { background: #5bd58e; color: #062012; }
.beacon-pip { background: #ffe17a; color: #382b05; box-shadow: 0 0 9px rgba(255, 224, 116, 0.88); }
.debuff-pip { background: #f05b41; color: white; box-shadow: 0 0 7px #d84b35; }
.barrier-pip { background: #f0ce61; color: #2e2508; box-shadow: 0 0 7px rgba(240, 206, 97, 0.75); }
.tank-aura-pip { background: #68bdf0; color: #062132; box-shadow: 0 0 7px rgba(104, 189, 240, 0.75); }
@@ -835,6 +908,14 @@ button:focus-visible {
.party-frame:hover { border-color: rgba(232, 200, 114, 0.35); }
.party-frame.is-selected { border-color: rgba(232, 200, 114, 0.66); border-left-color: var(--gold); background: linear-gradient(90deg, rgba(65, 57, 29, 0.42), rgba(12, 26, 22, 0.9)); transform: translateX(2px); }
.party-frame.is-beacon {
border-color: rgba(255, 218, 105, 0.62);
border-right-color: #ffdf79;
background: linear-gradient(90deg, rgba(52, 44, 15, 0.94), rgba(18, 29, 19, 0.88));
box-shadow: inset -14px 0 20px rgba(255, 216, 89, 0.11), 0 0 9px rgba(255, 218, 105, 0.16);
}
.party-frame.is-beacon.is-selected { border-left-color: #fff0a8; }
.party-frame.is-beacon .party-avatar { box-shadow: 0 0 10px rgba(255, 226, 125, 0.62), inset 0 0 12px rgba(255, 255, 255, 0.16); }
.party-frame.is-down { filter: grayscale(0.9); opacity: 0.48; }
.party-avatar {
@@ -908,6 +989,7 @@ button:focus-visible {
}
.shield-effect { border: 1px solid #63bfff; color: #9edbff; background: #153d58; }
.beacon-effect { border: 1px solid #ffe078; color: #fff2b0; background: #5c4912; box-shadow: 0 0 8px rgba(255, 224, 120, 0.7); }
.renew-effect { border: 1px solid #59c888; color: #b5f1cd; background: #163e29; }
.earth-shield-effect { border: 1px solid #d2b66c; color: #fff0ae; background: #51451b; }
.spirit-link-effect { border: 1px solid #a28cff; color: #e2d8ff; background: #352759; box-shadow: 0 0 7px rgba(162, 140, 255, 0.5); }
@@ -1526,6 +1608,11 @@ button:focus-visible {
.screen-label small { display: none; }
.display { box-shadow: 0 0 0 3px #080d0c, 0 0 0 4px rgba(152, 181, 171, 0.1), 0 12px 35px rgba(0,0,0,0.55); }
.top-party { width: 25%; gap: 2px; }
.top-conviction-meter { gap: 3px; padding: 3px 4px 4px; }
.top-conviction-meter b { font-size: 5px; }
.top-conviction-meter small { font-size: 6px; }
.top-conviction-pips { gap: 2px; }
.top-conviction-pips i { height: 3px; }
.top-party-member { min-height: 29px; grid-template-columns: 19px 1fr; gap: 4px; padding: 2px 4px 4px 2px; }
.portrait-dot { width: 18px; height: 18px; font-size: 8px; }
.top-party-copy strong { font-size: 7px; }
@@ -2035,21 +2122,30 @@ button:focus-visible {
.home-header > span { margin-left: auto; color: #8fa39b; font-size: 10px; }
.home-header > span b { color: #dce9e4; }
.home-header > i { color: #6ecaa7; font-size: 8px; font-style: normal; font-weight: 700; letter-spacing: 0.08em; }
.mode-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, 78px); grid-auto-rows: 78px; gap: 10px; margin-top: 18px; }
.mode-card { position: relative; display: grid; grid-template-columns: 54px 1fr 17px; align-items: center; gap: 12px; padding: 13px; overflow: hidden; text-align: left; }
.home-mode-sections { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 12px; }
.home-mode-section { --mode-section-color: var(--teal); min-width: 0; padding: 8px; border: 1px solid rgba(150,190,175,.19); border-top: 2px solid var(--mode-section-color); background: linear-gradient(145deg, color-mix(in srgb, var(--mode-section-color), transparent 94%), rgba(4,13,11,.72)); }
.home-mode-section.is-pvp { --mode-section-color: #e47ba5; }
.home-mode-section > header { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 2px; }
.home-mode-section > header > span { display: grid; }
.home-mode-section > header small { color: #70857d; font-size: 6px; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; }
.home-mode-section > header strong { color: var(--mode-section-color); font: 600 12px "Cinzel", serif; letter-spacing: .07em; text-transform: uppercase; }
.home-mode-section > header > b { color: #71867e; font-size: 6px; letter-spacing: .1em; text-transform: uppercase; }
.mode-grid { display: grid; gap: 6px; margin-top: 6px; }
.mode-grid-pve { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(3, 61px); }
.mode-grid-pvp { grid-template-columns: minmax(0, 1fr); grid-template-rows: repeat(3, 61px); }
.mode-card { position: relative; min-width: 0; display: grid; grid-template-columns: 37px minmax(0, 1fr) 10px; align-items: center; gap: 7px; padding: 7px; overflow: hidden; text-align: left; }
.mode-card::after { position: absolute; inset: 0; content: ""; background: linear-gradient(110deg, rgba(69,153,131,0.13), transparent 60%); pointer-events: none; }
.home-mode-section.is-pvp .mode-card::after { background: linear-gradient(110deg, rgba(228,123,165,.13), transparent 64%); }
.home-mode-section.is-pvp .mode-card > i { border-color: rgba(228,123,165,.5); color: #f0a1bf; }
.mode-card-blockbreaker::after { background: linear-gradient(110deg, rgba(54,217,239,.15), rgba(232,90,169,.1) 54%, rgba(147,219,84,.12)); }
.mode-card-blockbreaker > i { border-radius: 5px; color: #baf77e; box-shadow: inset 0 0 13px rgba(54,217,239,.09); }
.mode-card-aether-assault::after { background: linear-gradient(110deg, rgba(66, 222, 242, .18), rgba(100, 110, 225, .11) 62%, transparent); }
.mode-card-aether-assault > i { border-radius: 5px; color: #8cf5ff; box-shadow: inset 0 0 13px rgba(66, 222, 242, .1); }
.mode-card.is-wide { grid-row: 1 / 3; }
.mode-card > i { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,0.42); border-radius: 50%; color: var(--gold); background: rgba(2,9,7,0.45); font-family: "Cinzel", serif; font-size: 20px; font-style: normal; }
.mode-card.is-wide > i { width: 64px; height: 64px; font-size: 27px; }
.mode-card > i { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,0.42); border-radius: 50%; color: var(--gold); background: rgba(2,9,7,0.45); font-family: "Cinzel", serif; font-size: 15px; font-style: normal; }
.mode-card > span { z-index: 1; display: flex; flex-direction: column-reverse; }
.mode-card small { color: #72887f; font-size: 8px; text-transform: uppercase; }
.mode-card strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
.mode-card.is-wide strong { font-size: 19px; }
.mode-card > b { color: var(--gold); font-size: 21px; font-weight: 400; }
.mode-card small { overflow: hidden; color: #72887f; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.mode-card strong { overflow: hidden; font-family: "Cinzel", serif; font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.mode-card > b { color: var(--gold); font-size: 16px; font-weight: 400; }
.home-secondary-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.home-secondary-actions button { min-height: 58px; display: grid; grid-template-columns: 34px 1fr 12px; align-items: center; gap: 10px; padding: 8px 12px; text-align: left; }
.home-secondary-actions button > i { color: var(--teal); font-size: 20px; font-style: normal; }
@@ -2094,6 +2190,119 @@ button:focus-visible {
.change-save span { font-size: 10px; font-weight: 700; }
.change-save small { color: #647971; font-size: 7px; }
/* Class field guide */
.class-help-surface { padding: 0 24px 18px; }
.class-help-header { height: 58px; grid-template-columns: 176px minmax(0, 1fr) auto auto; }
.class-help-header > small { color: #71877e; font-size: 8px; font-weight: 700; letter-spacing: .12em; }
.guide-class-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-top: 8px; }
.guide-class-tabs button {
--guide-color: var(--gold);
min-width: 0;
height: 50px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 7px;
padding: 5px 7px;
text-align: left;
}
.guide-class-tabs button > i { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--guide-color), transparent 36%); border-radius: 50%; color: var(--guide-color); font: normal 13px "Cinzel", serif; }
.guide-class-tabs button > span { min-width: 0; display: grid; }
.guide-class-tabs strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.guide-class-tabs small { overflow: hidden; color: #657a72; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.guide-class-tabs button.is-selected { border-color: color-mix(in srgb, var(--guide-color), transparent 26%); background: linear-gradient(135deg, color-mix(in srgb, var(--guide-color), transparent 86%), rgba(6, 17, 14, .9)); }
.guide-class-intro {
--guide-color: var(--gold);
min-height: 64px;
display: grid;
grid-template-columns: 45px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
margin-top: 8px;
padding: 8px 12px;
border-left: 3px solid var(--guide-color);
background: linear-gradient(90deg, color-mix(in srgb, var(--guide-color), transparent 89%), rgba(6, 17, 14, .72));
}
.guide-class-intro > i { width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--guide-color), transparent 35%); color: var(--guide-color); font: normal 21px "Cinzel", serif; transform: rotate(45deg); }
.guide-class-intro > span { min-width: 0; display: grid; }
.guide-class-intro small { color: var(--guide-color); font-size: 7px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.guide-class-intro h2 { margin: 1px 0; font: 500 14px "Cinzel", serif; }
.guide-class-intro p { margin: 0; overflow: hidden; color: #81968e; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.guide-class-intro > b { padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--guide-color), transparent 70%); color: #aebfb8; background: rgba(3, 11, 9, .5); font-size: 7px; letter-spacing: .08em; text-transform: uppercase; }
.guide-ability-grid { height: calc(100% - 206px); min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 8px; }
.guide-ability-card {
--ability-color: var(--gold);
position: relative;
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
gap: 5px 9px;
padding: 9px;
overflow: hidden;
text-align: left;
}
.guide-ability-card::after { position: absolute; inset: 0; content: ""; pointer-events: none; background: radial-gradient(circle at 8% 20%, color-mix(in srgb, var(--ability-color), transparent 87%), transparent 45%); }
.guide-ability-card.is-selected { border-color: color-mix(in srgb, var(--ability-color), transparent 24%); box-shadow: inset 3px 0 var(--ability-color); background: linear-gradient(120deg, color-mix(in srgb, var(--ability-color), transparent 88%), rgba(7, 18, 15, .92)); }
.guide-ability-icon { position: relative; z-index: 1; align-self: start; display: grid; justify-items: center; gap: 4px; }
.guide-ability-icon > b { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--ability-color), transparent 36%); border-radius: 50%; color: var(--ability-color); background: rgba(2, 9, 7, .62); font: 500 19px "Cinzel", serif; }
.guide-ability-icon > small { color: #7b9188; font-size: 7px; font-weight: 700; }
.guide-ability-copy { position: relative; z-index: 1; min-width: 0; display: grid; align-content: start; gap: 4px; }
.guide-ability-copy > strong { overflow: hidden; font: 500 11px "Cinzel", serif; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.guide-ability-copy > small { display: -webkit-box; overflow: hidden; color: #84978f; font-size: 8px; line-height: 1.25; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.guide-ability-meta { position: relative; z-index: 1; grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 4px; padding-top: 5px; border-top: 1px solid color-mix(in srgb, var(--ability-color), transparent 82%); }
.guide-ability-meta > b { color: #71867e; font-size: 6px; letter-spacing: .06em; text-transform: uppercase; }
.class-help-surface > .controller-legend { position: absolute; right: 25px; bottom: 5px; }
.class-help-context { padding: 0 22px 10px; }
.class-help-context .context-header { height: 40px; margin: 0 -22px; padding: 0 22px; }
.guide-context-class {
--guide-color: var(--gold);
min-height: 79px;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 11px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.guide-context-class > i { width: 46px; height: 46px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--guide-color), transparent 35%); border-radius: 50%; color: var(--guide-color); background: color-mix(in srgb, var(--guide-color), transparent 92%); font: normal 22px "Cinzel", serif; }
.guide-context-class > span { min-width: 0; display: grid; }
.guide-context-class small { color: var(--guide-color); font-size: clamp(6px, 1.35cqw, 8px); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.guide-context-class h2 { margin: 1px 0; font: 500 clamp(13px, 2.7cqw, 17px) "Cinzel", serif; }
.guide-context-class p { margin: 1px 0 0; color: #80948d; font-size: clamp(7px, 1.48cqw, 9px); line-height: 1.2; }
.guide-core-loop { padding: 7px 0; border-bottom: 1px solid var(--line); }
.guide-core-loop > header { display: flex; justify-content: space-between; color: #71867e; font-size: 7px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.guide-core-loop > header b { color: var(--gold); }
.guide-core-loop > p { min-height: 25px; display: grid; grid-template-columns: 24px 1fr; align-items: center; gap: 6px; margin: 2px 0 0; color: #a4b5ae; font-size: clamp(7px, 1.55cqw, 9px); line-height: 1.15; }
.guide-core-loop > p i { color: #72887f; font-size: 7px; font-style: normal; }
.guide-selected-detail { --ability-color: var(--gold); flex: 1; min-height: 0; padding-top: 7px; overflow: hidden; }
.guide-selected-detail > header { min-height: 38px; display: grid; grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: 8px; }
.guide-selected-detail > header > i { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--ability-color), transparent 32%); border-radius: 50%; color: var(--ability-color); font: normal 16px "Cinzel", serif; }
.guide-selected-detail > header > span { display: grid; }
.guide-selected-detail > header small { color: #6e827a; font-size: 6px; letter-spacing: .1em; text-transform: uppercase; }
.guide-selected-detail h3 { margin: 0; font: 500 clamp(12px, 2.6cqw, 16px) "Cinzel", serif; }
.guide-selected-detail > p { margin: 3px 0 5px; color: #aabbb4; font-size: clamp(7px, 1.55cqw, 9px); line-height: 1.2; }
.guide-selected-detail > aside { display: grid; grid-template-columns: 47px 1fr; gap: 6px; padding: 5px 7px; border-left: 2px solid var(--ability-color); background: color-mix(in srgb, var(--ability-color), transparent 93%); }
.guide-selected-detail > aside b { color: var(--ability-color); font-size: 6px; letter-spacing: .08em; text-transform: uppercase; }
.guide-selected-detail > aside span { color: #879b93; font-size: clamp(6px, 1.4cqw, 8px); line-height: 1.15; }
.guide-synergy-list { display: grid; gap: 3px; margin-top: 5px; }
.guide-synergy-list > span { color: #6c8179; font-size: 6px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.guide-synergy-list article { min-height: 34px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 3px 6px; border: 1px solid rgba(145, 181, 168, .14); background: rgba(5, 16, 13, .72); }
.guide-synergy-list article > i { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--ability-color), transparent 58%); border-radius: 50%; color: var(--ability-color); font-size: 10px; font-style: normal; }
.guide-synergy-list article > span { min-width: 0; display: grid; }
.guide-synergy-list article strong { font-size: clamp(7px, 1.48cqw, 9px); }
.guide-synergy-list article small { overflow: hidden; color: #748980; font-size: clamp(6px, 1.28cqw, 8px); line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; }
.guide-synergy-list article > b { padding: 2px 4px; color: var(--ability-color); background: color-mix(in srgb, var(--ability-color), transparent 91%); font-size: 5px; letter-spacing: .06em; }
/* Profile */
.profile-surface { padding: 0 30px; }
@@ -2470,11 +2679,19 @@ button:focus-visible {
.version-actions button small { font-size: 6px; }
.home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; }
.mode-grid { grid-template-rows: repeat(2, 43px); grid-auto-rows: 43px; gap: 5px; margin-top: 6px; }
.mode-card { grid-template-columns: 25px 1fr 8px; gap: 4px; padding: 4px; }
.mode-card > i, .mode-card.is-wide > i { width: 23px; height: 23px; font-size: 10px; }
.mode-card strong, .mode-card.is-wide strong { font-size: 8px; }
.mode-card small { font-size: 5px; }
.home-mode-sections { gap: 5px; margin-top: 5px; }
.home-mode-section { padding: 4px; }
.home-mode-section > header { min-height: 17px; gap: 3px; }
.home-mode-section > header small,
.home-mode-section > header > b { font-size: 4px; }
.home-mode-section > header strong { font-size: 7px; }
.mode-grid { gap: 3px; margin-top: 3px; }
.mode-grid-pve,
.mode-grid-pvp { grid-template-rows: repeat(3, 39px); }
.mode-card { grid-template-columns: 25px minmax(0, 1fr) 7px; gap: 4px; padding: 4px; }
.mode-card > i { width: 23px; height: 23px; font-size: 10px; }
.mode-card strong { font-size: 7px; }
.mode-card small { font-size: 4px; }
.home-secondary-actions { gap: 5px; margin-top: 5px; }
.home-secondary-actions button { min-height: 34px; grid-template-columns: 18px 1fr 6px; gap: 4px; padding: 3px 6px; }
.home-secondary-actions button > i { font-size: 10px; }
@@ -2759,6 +2976,41 @@ button:focus-visible {
.gear-passive-ability-filter button { min-height: 14px; padding: 1px 2px; font-size: 3px; }
}
@media (min-width: 761px) {
.home-secondary-actions { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.home-secondary-actions button { grid-template-columns: 27px minmax(0, 1fr) 8px; gap: 6px; padding-right: 8px; padding-left: 8px; }
.home-secondary-actions button > i { font-size: 17px; }
.home-secondary-actions strong { font-size: 10px; }
.home-secondary-actions small { overflow: hidden; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
}
@media (max-width: 760px) {
.class-help-surface { padding: 0 10px 8px; }
.class-help-header { grid-template-columns: 125px minmax(0, 1fr) auto; }
.class-help-header > small { display: none; }
.guide-class-tabs { gap: 3px; margin-top: 5px; }
.guide-class-tabs button { height: 38px; grid-template-columns: 20px minmax(0, 1fr); gap: 3px; padding: 3px 4px; }
.guide-class-tabs button > i { width: 19px; height: 19px; font-size: 8px; }
.guide-class-tabs strong { font-size: 6px; }
.guide-class-tabs small { display: none; }
.guide-class-intro { min-height: 47px; grid-template-columns: 31px minmax(0, 1fr) auto; gap: 6px; margin-top: 5px; padding: 4px 7px; }
.guide-class-intro > i { width: 28px; height: 28px; font-size: 13px; }
.guide-class-intro small { font-size: 5px; }
.guide-class-intro h2 { font-size: 9px; }
.guide-class-intro p { font-size: 5px; }
.guide-class-intro > b { padding: 2px 4px; font-size: 4px; }
.guide-ability-grid { height: calc(100% - 144px); gap: 3px; margin-top: 5px; }
.guide-ability-card { grid-template-columns: 27px minmax(0, 1fr); gap: 2px 4px; padding: 4px; }
.guide-ability-icon > b { width: 25px; height: 25px; font-size: 11px; }
.guide-ability-icon > small { font-size: 4px; }
.guide-ability-copy { gap: 2px; }
.guide-ability-copy > strong { font-size: 6px; }
.guide-ability-copy > small { font-size: 5px; -webkit-line-clamp: 2; }
.guide-ability-meta { padding-top: 2px; }
.guide-ability-meta > b { font-size: 3px; }
.class-help-surface > .controller-legend { display: none; }
}
/* Appearance Lab */
.appearance-surface { padding: 0 24px 15px; }