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