From 40aa23be46f8df8e979ac36125145c3b379788fc Mon Sep 17 00:00:00 2001 From: Warren H Date: Sat, 18 Jul 2026 12:05:34 -0400 Subject: [PATCH] new help section explaining classes and their mechanics --- package.json | 1 + scripts/build_priest_palette.mjs | 66 ++++ .../chars/players/priest-vestments.webp | Bin 0 -> 7852 bytes src/components/BottomScreen.tsx | 19 +- src/components/FrontEnd.tsx | 206 +++++++++-- src/components/GameScene.tsx | 111 +++++- src/components/HealerClassAccessory.tsx | 51 ++- src/components/ModularCharacterBody.tsx | 90 ++++- src/components/TopScreen.tsx | 39 +- src/components/sceneFramePolicy.test.ts | 21 +- src/components/sceneFramePolicy.ts | 24 +- src/frontend/appearanceStore.test.ts | 4 +- src/frontend/classHelpStore.test.ts | 42 +++ src/frontend/store.ts | 24 ++ src/frontend/types.ts | 2 +- src/game/appearanceLab.ts | 4 + src/game/characterAppearance.ts | 6 + src/game/healerClasses.test.ts | 10 +- src/game/healerGuides.test.ts | 35 ++ src/game/healerGuides.ts | 335 ++++++++++++++++++ src/game/healerMechanics.test.ts | 12 + src/game/healerMechanics.ts | 15 +- src/game/healerVisuals.test.ts | 49 +++ src/game/healerVisuals.ts | 57 ++- src/game/healers.ts | 4 +- src/game/store.test.ts | 10 +- src/game/store.ts | 6 +- src/game/weaponCatalog.ts | 2 +- src/platform/BottomDisplayApp.tsx | 4 + src/platform/dualScreenSync.test.ts | 22 ++ src/platform/dualScreenSync.ts | 6 + src/styles.css | 280 ++++++++++++++- 32 files changed, 1423 insertions(+), 134 deletions(-) create mode 100644 scripts/build_priest_palette.mjs create mode 100644 src/assets/game/textures/claudecraft/chars/players/priest-vestments.webp create mode 100644 src/frontend/classHelpStore.test.ts create mode 100644 src/game/healerGuides.test.ts create mode 100644 src/game/healerGuides.ts diff --git a/package.json b/package.json index 650e726..36482af 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/build_priest_palette.mjs b/scripts/build_priest_palette.mjs new file mode 100644 index 0000000..1ff37f6 --- /dev/null +++ b/scripts/build_priest_palette.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)}`); diff --git a/src/assets/game/textures/claudecraft/chars/players/priest-vestments.webp b/src/assets/game/textures/claudecraft/chars/players/priest-vestments.webp new file mode 100644 index 0000000000000000000000000000000000000000..4d6e3ef27e36a05432fe836a64e61c6ab5b9eb95 GIT binary patch literal 7852 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 KtZgF>UVs3Q9f$z{ literal 0 HcmV?d00001 diff --git a/src/components/BottomScreen.tsx b/src/components/BottomScreen.tsx index ae4cec8..83bd1ae 100644 --- a/src/components/BottomScreen.tsx +++ b/src/components/BottomScreen.tsx @@ -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 ( ); })} + ); } diff --git a/src/components/sceneFramePolicy.test.ts b/src/components/sceneFramePolicy.test.ts index ecd9f60..f3ceeaf 100644 --- a/src/components/sceneFramePolicy.test.ts +++ b/src/components/sceneFramePolicy.test.ts @@ -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(); }); diff --git a/src/components/sceneFramePolicy.ts b/src/components/sceneFramePolicy.ts index c2c9a02..e2f9648 100644 --- a/src/components/sceneFramePolicy.ts +++ b/src/components/sceneFramePolicy.ts @@ -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; +// 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, 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; diff --git a/src/frontend/appearanceStore.test.ts b/src/frontend/appearanceStore.test.ts index 0ed5cb7..a8921f0 100644 --- a/src/frontend/appearanceStore.test.ts +++ b/src/frontend/appearanceStore.test.ts @@ -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"); diff --git a/src/frontend/classHelpStore.test.ts b/src/frontend/classHelpStore.test.ts new file mode 100644 index 0000000..90c5d1b --- /dev/null +++ b/src/frontend/classHelpStore.test.ts @@ -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); + }); +}); diff --git a/src/frontend/store.ts b/src/frontend/store.ts index 9923471..ad50a4b 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -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((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((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>[] = [ + { 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>[] = [ ]; const UPPER_CHOICES: readonly AppearanceChoice>[] = [ + { 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> ]; const LOWER_CHOICES: readonly AppearanceChoice>[] = [ + { 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 const BACK_CHOICES: readonly AppearanceChoice | 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" }, diff --git a/src/game/characterAppearance.ts b/src/game/characterAppearance.ts index 9ad0a39..8636a34 100644 --- a/src/game/characterAppearance.ts +++ b/src/game/characterAppearance.ts @@ -22,26 +22,31 @@ export const CHARACTER_WEAPON_GRIP_BY_MODEL = Object.fromEntries( ) as Record; 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"] }, diff --git a/src/game/healerClasses.test.ts b/src/game/healerClasses.test.ts index 0a40f75..b269ba1 100644 --- a/src/game/healerClasses.test.ts +++ b/src/game/healerClasses.test.ts @@ -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", () => { diff --git a/src/game/healerGuides.test.ts b/src/game/healerGuides.test.ts new file mode 100644 index 0000000..af3f048 --- /dev/null +++ b/src/game/healerGuides.test.ts @@ -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); + } + }); +}); diff --git a/src/game/healerGuides.ts b/src/game/healerGuides.ts new file mode 100644 index 0000000..8828af7 --- /dev/null +++ b/src/game/healerGuides.ts @@ -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; +} + +export const HEALER_GUIDES: Record = { + 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." }, + ], + }, + }, + }, +}; diff --git a/src/game/healerMechanics.test.ts b/src/game/healerMechanics.test.ts index 4b61708..0097745 100644 --- a/src/game/healerMechanics.test.ts +++ b/src/game/healerMechanics.test.ts @@ -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 } diff --git a/src/game/healerMechanics.ts b/src/game/healerMechanics.ts index 60c1e56..6a4f476 100644 --- a/src/game/healerMechanics.ts +++ b/src/game/healerMechanics.ts @@ -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); diff --git a/src/game/healerVisuals.test.ts b/src/game/healerVisuals.test.ts index 46cad56..fb789c4 100644 --- a/src/game/healerVisuals.test.ts +++ b/src/game/healerVisuals.test.ts @@ -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" }, + }); + }); }); diff --git a/src/game/healerVisuals.ts b/src/game/healerVisuals.ts index 6f553e8..03c71d2 100644 --- a/src/game/healerVisuals.ts +++ b/src/game/healerVisuals.ts @@ -34,17 +34,17 @@ export const HEALER_VISUAL_PROFILES: Record 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 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 }, }; +const LEGACY_HEALER_DEFAULTS: Partial> = { + 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) { diff --git a/src/game/healers.ts b/src/game/healers.ts index 188fdd7..ec2453d 100644 --- a/src/game/healers.ts +++ b/src/game/healers.ts @@ -50,7 +50,7 @@ export const HEALER_CLASSES: Record = { 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 = { 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: { diff --git a/src/game/store.test.ts b/src/game/store.test.ts index 85be685..bf50bd5 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -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; diff --git a/src/game/store.ts b/src/game/store.ts index 65ab002..aaff98b 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -267,7 +267,7 @@ const emptyCooldowns = (): Record => ({ 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((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((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; diff --git a/src/game/weaponCatalog.ts b/src/game/weaponCatalog.ts index b96542b..2f5cf7c 100644 --- a/src/game/weaponCatalog.ts +++ b/src/game/weaponCatalog.ts @@ -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 }, diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index e7ad92c..912e122 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -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 }), diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index dc9ea48..a7d07c4 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -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, + }); + }); }); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 2d1bde7..b78b8da 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -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; diff --git a/src/styles.css b/src/styles.css index 6092cf3..52b6bf7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }