forked from phenom/healer-man
Release Healer Man 0.1.43
This commit is contained in:
1 parent
c7e8163790
commit
ccc01396cd
86 files changed
+2951
-198
No files matched your search
@@ -10,7 +10,7 @@ import {
|
||||
type ActionBindingLayer,
|
||||
} from "../game/actionBindings";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
@@ -20,6 +20,7 @@ import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
|
||||
|
||||
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -97,7 +98,7 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
|
||||
},
|
||||
};
|
||||
|
||||
function AbilityBindingsDialog() {
|
||||
function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const keyLabel = useInputBindingLabels();
|
||||
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
|
||||
@@ -106,6 +107,19 @@ function AbilityBindingsDialog() {
|
||||
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
|
||||
if (!definition) return null;
|
||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||||
const ability = abilityAtLevel(definition, level, talentRank);
|
||||
return {
|
||||
name: ability.name, description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
|
||||
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||||
};
|
||||
};
|
||||
const bindings = useCombatStore((state) => state.actionBindings);
|
||||
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
|
||||
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
|
||||
@@ -133,7 +147,7 @@ function AbilityBindingsDialog() {
|
||||
]
|
||||
: ordered;
|
||||
}, [abilities, editingAbilityId]);
|
||||
const close = () => openOverlay("pause");
|
||||
const close = onClose ?? (() => openOverlay("pause"));
|
||||
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
|
||||
try {
|
||||
const ui = useUiLayoutStore.getState();
|
||||
@@ -335,6 +349,7 @@ function AbilityBindingsDialog() {
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
const locked = Boolean(ability && ability.unlockLevel > level);
|
||||
return (
|
||||
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
|
||||
<ControllerButton
|
||||
key={control}
|
||||
controlId={slotControlId(control)}
|
||||
@@ -352,6 +367,7 @@ function AbilityBindingsDialog() {
|
||||
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -363,7 +379,7 @@ function AbilityBindingsDialog() {
|
||||
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
|
||||
<h2 id="ability-bindings-title">Ability controls</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
|
||||
</header>
|
||||
|
||||
<nav className="ability-bindings__layouts" aria-label="Action bar style">
|
||||
@@ -407,14 +423,15 @@ function AbilityBindingsDialog() {
|
||||
const ability = abilities.find((candidate) => candidate.id === slot.abilityId);
|
||||
const locked = Boolean(ability && ability.unlockLevel > level);
|
||||
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty");
|
||||
return <ControllerButton key={index} controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
|
||||
return <AbilityTooltipTarget key={index} ability={tooltipData(ability)} bindingLabel={controlLabel(index)}>
|
||||
<ControllerButton controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
|
||||
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
|
||||
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
|
||||
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
|
||||
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
|
||||
</ControllerButton>;
|
||||
</ControllerButton></AbilityTooltipTarget>;
|
||||
})}
|
||||
</div>
|
||||
<p className="ability-bindings__wow-note">{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}</p>
|
||||
@@ -475,10 +492,12 @@ function AbilityBindingsDialog() {
|
||||
<h3>Choose an ability</h3>
|
||||
<div className="ability-bindings__choices">
|
||||
{pickerAbilities.map((ability) => (
|
||||
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
|
||||
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
||||
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
))}
|
||||
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
|
||||
<span className="ability-bindings__empty" aria-hidden="true">×</span>
|
||||
@@ -507,7 +526,7 @@ function AbilityBindingsDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AbilityBindingsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "bindings");
|
||||
return open ? <AbilityBindingsDialog /> : null;
|
||||
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "bindings");
|
||||
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AbilityTooltipContent, AbilityTooltipTarget, abilityTooltipFacts, abilityTooltipPosition,
|
||||
type AbilityTooltipData,
|
||||
} from "./AbilityTooltip";
|
||||
|
||||
const ability: AbilityTooltipData = {
|
||||
name: "Healing Wave", description: "Heals a friendly target for 120 health.",
|
||||
resourceCost: 15, resourceLabel: "mana", cooldownSeconds: 6,
|
||||
castSeconds: 1.5, range: 40, target: "friendly",
|
||||
};
|
||||
|
||||
describe("ability tooltips", () => {
|
||||
it("shows the ability's description, binding, and gameplay facts", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, { ability, bindingLabel: "Shift+1" }));
|
||||
for (const text of [ability.name, ability.description!, "Shift+1", "15 mana", "1.5 sec cast", "6 sec cooldown", "40 m range", "Selected ally or self"]) {
|
||||
expect(markup).toContain(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("explains free, instant abilities and distinguishes no cooldown from missing data", () => {
|
||||
expect(abilityTooltipFacts({ name: "Free", resourceCost: 0, castSeconds: 0, cooldownSeconds: 0 }))
|
||||
.toEqual(["No cost", "Instant", "No cooldown"]);
|
||||
expect(abilityTooltipFacts({ name: "Unknown" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows unlock and talent requirements without hiding the description", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, {
|
||||
ability: { ...ability, lockedUntilLevel: 20, talentLocked: true },
|
||||
}));
|
||||
expect(markup).toContain(ability.description);
|
||||
expect(markup).toContain("Unlocks at level 20");
|
||||
expect(markup).toContain("Learn in talents");
|
||||
});
|
||||
|
||||
it("does not present active cast facts for passive abilities", () => {
|
||||
expect(abilityTooltipFacts({ ...ability, passive: true, rank: 2 })).toEqual(["Passive", "Rank 2"]);
|
||||
});
|
||||
|
||||
it("preserves inspectable unavailable controls and creates no layout wrapper", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
|
||||
ability,
|
||||
children: createElement("button", { "aria-disabled": true, title: "Old browser tooltip" }, "Healing Wave"),
|
||||
}));
|
||||
expect(markup).toBe('<button aria-disabled="true">Healing Wave</button>');
|
||||
});
|
||||
|
||||
it("keeps the empty-slot browser hint without creating an ability tooltip", () => {
|
||||
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
|
||||
ability: null,
|
||||
children: createElement("button", { title: "Empty binding" }, "Empty"),
|
||||
}));
|
||||
expect(markup).toBe('<button title="Empty binding">Empty</button>');
|
||||
});
|
||||
|
||||
it("places a bottom-bar tooltip above its control", () => {
|
||||
expect(abilityTooltipPosition({ left: 400, right: 440, top: 700, bottom: 740 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 270, top: 512 });
|
||||
});
|
||||
|
||||
it("flips below top-edge controls and clamps to either horizontal edge", () => {
|
||||
expect(abilityTooltipPosition({ left: 0, right: 40, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 12, top: 68 });
|
||||
expect(abilityTooltipPosition({ left: 960, right: 1000, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
|
||||
.toEqual({ left: 688, top: 68 });
|
||||
});
|
||||
|
||||
it("keeps a tall tooltip inside a narrow viewport", () => {
|
||||
expect(abilityTooltipPosition({ left: 200, right: 240, top: 120, bottom: 160 }, { width: 296, height: 400 }, { width: 320, height: 480 }))
|
||||
.toEqual({ left: 12, top: 68 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
|
||||
type ButtonHTMLAttributes, type ReactElement,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
|
||||
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
|
||||
|
||||
export interface AbilityTooltipData {
|
||||
name: string;
|
||||
description?: string;
|
||||
resourceCost?: number;
|
||||
resourceLabel?: string;
|
||||
cooldownSeconds?: number;
|
||||
castSeconds?: number;
|
||||
range?: number;
|
||||
target?: AbilityTarget;
|
||||
passive?: boolean;
|
||||
rank?: number;
|
||||
lockedUntilLevel?: number;
|
||||
talentLocked?: boolean;
|
||||
}
|
||||
|
||||
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
|
||||
const facts: string[] = [];
|
||||
if (ability.passive) facts.push("Passive");
|
||||
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
|
||||
if (!ability.passive) {
|
||||
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
|
||||
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
|
||||
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
|
||||
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
|
||||
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
|
||||
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function abilityTooltipPosition(
|
||||
anchor: { left: number; right: number; top: number; bottom: number },
|
||||
size: { width: number; height: number },
|
||||
viewport: { width: number; height: number },
|
||||
): { left: number; top: number } {
|
||||
const margin = 12;
|
||||
const gap = 8;
|
||||
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
|
||||
const above = anchor.top - size.height - gap;
|
||||
return {
|
||||
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
|
||||
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
|
||||
return <>
|
||||
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
|
||||
<p>{ability.description || "No description available for this ability."}</p>
|
||||
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
|
||||
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
|
||||
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
|
||||
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
|
||||
ability: AbilityTooltipData | null | undefined;
|
||||
bindingLabel?: string;
|
||||
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
const tooltip = useRef<HTMLDivElement>(null);
|
||||
const hovering = useRef(false);
|
||||
const keyboardFocused = useRef(false);
|
||||
const visible = Boolean(anchor && ability);
|
||||
const inspect = (element: HTMLButtonElement) => {
|
||||
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
|
||||
setAnchor(element);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !tooltip.current || !ability) return;
|
||||
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
|
||||
width: window.innerWidth, height: window.innerHeight,
|
||||
}));
|
||||
}, [anchor, ability, bindingLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const dismiss = () => {
|
||||
hovering.current = false;
|
||||
keyboardFocused.current = false;
|
||||
setAnchor(null);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
|
||||
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
|
||||
window.addEventListener(INSPECT_EVENT, onInspect);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", dismiss);
|
||||
window.addEventListener("resize", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
return () => {
|
||||
window.removeEventListener(INSPECT_EVENT, onInspect);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", dismiss);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
};
|
||||
}, [visible, id]);
|
||||
|
||||
const original = children.props;
|
||||
return <>
|
||||
{cloneElement(children, {
|
||||
title: ability ? undefined : original.title,
|
||||
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
|
||||
onPointerEnter: (event) => {
|
||||
original.onPointerEnter?.(event);
|
||||
if (event.pointerType === "touch" || !ability) return;
|
||||
hovering.current = true;
|
||||
inspect(event.currentTarget);
|
||||
},
|
||||
onPointerLeave: (event) => {
|
||||
original.onPointerLeave?.(event);
|
||||
hovering.current = false;
|
||||
if (!keyboardFocused.current) setAnchor(null);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
original.onFocus?.(event);
|
||||
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
|
||||
if (keyboardFocused.current && ability) inspect(event.currentTarget);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
original.onBlur?.(event);
|
||||
keyboardFocused.current = false;
|
||||
if (!hovering.current) setAnchor(null);
|
||||
},
|
||||
})}
|
||||
{visible && createPortal(
|
||||
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
|
||||
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
|
||||
</div>, document.body,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import {
|
||||
ACTION_LAYER_LABELS,
|
||||
@@ -21,6 +22,10 @@ export interface ActionBarItem {
|
||||
disabled?: boolean;
|
||||
lockedUntilLevel?: number;
|
||||
target?: AbilityTarget;
|
||||
cooldownSeconds?: number;
|
||||
castSeconds?: number;
|
||||
range?: number;
|
||||
talentLocked?: boolean;
|
||||
}
|
||||
|
||||
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
|
||||
@@ -94,6 +99,7 @@ function ActionSlot({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
|
||||
<button
|
||||
type="button"
|
||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||
@@ -124,6 +130,7 @@ function ActionSlot({
|
||||
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
|
||||
)}
|
||||
</button>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useArenaStore } from "../game/arenaStore";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
|
||||
|
||||
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
|
||||
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
|
||||
cooldownRemaining: remaining,
|
||||
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
|
||||
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
|
||||
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
|
||||
}];
|
||||
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
|
||||
{bar.slots.map((slot, slotIndex) => {
|
||||
const item = slot.abilityId ? items[slot.abilityId] : null;
|
||||
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
|
||||
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
|
||||
<button type="button" aria-disabled={!item || item.disabled}
|
||||
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
|
||||
onClick={() => item && onCast(item.id)}>
|
||||
onClick={() => item && !item.disabled && onCast(item.id)}>
|
||||
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
|
||||
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>)}
|
||||
</nav>
|
||||
|
||||
@@ -8,12 +8,13 @@ describe("boss card display", () => {
|
||||
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
|
||||
const boss = BOSS_CARD_CATALOG[0];
|
||||
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
|
||||
onClose: () => {}, characterName: "Collector",
|
||||
onClose: () => {},
|
||||
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
|
||||
}));
|
||||
expect(markup).toContain("7 copies");
|
||||
expect(markup).toContain("1 copy");
|
||||
expect(markup).toContain("8 total copies");
|
||||
expect(markup).toContain("Account-wide collection log");
|
||||
expect(markup).toContain("Group by dungeon");
|
||||
expect(markup).toContain("Group by rarity");
|
||||
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import "./bossCards.css";
|
||||
|
||||
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
|
||||
const EMPTY_COLLECTION = emptyBossCardCollection();
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
export function BossCardCollection({ onClose, collection }: {
|
||||
readonly onClose: () => void;
|
||||
readonly collection: Collection;
|
||||
readonly characterName: string;
|
||||
}) {
|
||||
useContentManagerSnapshot();
|
||||
const [earnedOnly, setEarnedOnly] = useState(false);
|
||||
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
|
||||
<header className="boss-collection__header">
|
||||
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
|
||||
</header>
|
||||
<div className="boss-collection__summary">
|
||||
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
}
|
||||
|
||||
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
|
||||
const savedCharacter = useShellStore(selectedCharacter);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const liveCollection = useCombatStore((state) => state.bossCards);
|
||||
const character = activeCharacter ?? savedCharacter;
|
||||
const collection = useShellStore((state) => state.bossCards);
|
||||
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
|
||||
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
|
||||
collection={collection} />;
|
||||
}
|
||||
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
|
||||
|
||||
function BossGuideDialog() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
|
||||
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
|
||||
?? selectedDungeon?.bosses[0];
|
||||
const close = () => openOverlay("pause");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
|
||||
import { bossTestIsDefeated } from "../game/BossTestingBridge";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import "./bossTesting.css";
|
||||
|
||||
export function BossTestingReviewPanel() {
|
||||
const boss = useGameStore((state) => state.bossTest);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const review = useBossTestingReviews((state) => boss ? state.reviews[boss.key] : undefined);
|
||||
const save = useBossTestingReviews((state) => state.save);
|
||||
const refresh = useBossTestingReviews((state) => state.refresh);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const busy = useRef(false);
|
||||
const mark = async (status: BossTestingStatus) => {
|
||||
if (!boss || busy.current) return;
|
||||
busy.current = true; setSaving(true); setMessage("");
|
||||
const synced = await save(boss, status, session);
|
||||
setMessage(synced ? `Marked ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}.` : "Saved on this device. This label will sync when you reconnect or reopen Boss Testing.");
|
||||
busy.current = false; setSaving(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
void refresh(session);
|
||||
const reconnect = () => void refresh(session);
|
||||
window.addEventListener("online", reconnect);
|
||||
return () => window.removeEventListener("online", reconnect);
|
||||
}, [refresh, session]);
|
||||
const defeated = bossTestIsDefeated();
|
||||
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
|
||||
const retry = () => useGameStore.getState().resetAtEntrance();
|
||||
const leave = () => useShellStore.getState().returnToMainMenu();
|
||||
const actions: MenuAction[] = [
|
||||
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
|
||||
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
|
||||
{ id: "boss-review-retry", run: retry, enabled: !saving },
|
||||
{ id: "boss-review-leave", run: leave, enabled: !saving },
|
||||
{ id: "boss-review-back", run: back, enabled: !saving },
|
||||
];
|
||||
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
|
||||
if (!boss) return null;
|
||||
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
|
||||
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
|
||||
return <div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
|
||||
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
|
||||
<p>Current readiness: <strong>{BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}</strong></p>
|
||||
<div className="boss-testing-actions" aria-label="Boss readiness">{BOSS_TESTING_STATUSES.map((status) =>
|
||||
<span key={status}>{button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)}</span>
|
||||
)}</div>
|
||||
{message && <p role="status">{message}</p>}
|
||||
<div className="pause-menu__actions">
|
||||
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
|
||||
{button("boss-review-retry", "Fight this boss again", retry)}
|
||||
{button("boss-review-leave", "Return to main menu", leave)}
|
||||
{button("boss-review-back", "Back to pause menu", back)}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
|
||||
import { BOSS_TESTING_STATUS_LABELS, useBossTestingReviews } from "../app/bossTestingReviews";
|
||||
import { BOSS_TESTING_CATALOG, randomTestingBoss, type TestableBoss } from "../game/bossTesting";
|
||||
import { defaultPartyRoleForClass, partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyRoles";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import "./bossTesting.css";
|
||||
|
||||
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const reviews = useBossTestingReviews((state) => state.reviews);
|
||||
const refresh = useBossTestingReviews((state) => state.refresh);
|
||||
const content = useContentManagerSnapshot();
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const [preparing, setPreparing] = useState(false);
|
||||
const [prepared, setPrepared] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
|
||||
const busy = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
|
||||
(category === "all" || boss.category === category)
|
||||
&& (status === "all" || (reviews[boss.key]?.status ?? "needs-testing") === status)
|
||||
&& `${boss.name} ${boss.dungeonTitle}`.toLowerCase().includes(query.trim().toLowerCase())
|
||||
), [category, query, reviews, status]);
|
||||
const selected = BOSS_TESTING_CATALOG.find((boss) => boss.key === selectedKey);
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const roles = character ? specialization ? [specialization.role] : partyRolesForClass(character.classId) : [];
|
||||
const effectiveRole = specialization?.role ?? role;
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void refresh(session);
|
||||
const reconnect = () => void refresh(session);
|
||||
window.addEventListener("online", reconnect);
|
||||
return () => { alive.current = false; window.removeEventListener("online", reconnect); };
|
||||
}, [refresh, session]);
|
||||
|
||||
const choose = async (boss: TestableBoss, start = false) => {
|
||||
if (busy.current || !character) return;
|
||||
busy.current = true;
|
||||
setSelectedKey(boss.key); setPreparing(true); setPrepared(false); setError("");
|
||||
try {
|
||||
await prepareBossTesting(boss);
|
||||
if (!alive.current) return;
|
||||
setPrepared(true);
|
||||
if (start) enterBossTesting(boss, effectiveRole);
|
||||
} catch (failure) {
|
||||
if (alive.current) setError(failure instanceof Error ? failure.message : "The boss could not be prepared. Please retry.");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (alive.current) setPreparing(false);
|
||||
}
|
||||
};
|
||||
const testSelected = () => {
|
||||
if (!selected || preparing) return;
|
||||
if (!prepared) { void choose(selected, true); return; }
|
||||
try { enterBossTesting(selected, effectiveRole); } catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "The test could not be started.");
|
||||
}
|
||||
};
|
||||
const random = () => {
|
||||
const boss = randomTestingBoss(bosses);
|
||||
if (boss) void choose(boss, true);
|
||||
};
|
||||
const actions: MenuAction[] = [
|
||||
{ id: "boss-testing-back", run: onClose },
|
||||
{ id: "boss-testing-category", run: () => setCategory(category === "all" ? "wow" : category === "wow" ? "rom" : "all"), enabled: !preparing },
|
||||
{ id: "boss-testing-status", run: () => { const values = ["all", "ready", "needs-testing", "not-ready"]; setStatus(values[(values.indexOf(status) + 1) % values.length]); }, enabled: !preparing },
|
||||
{ id: "boss-testing-picker", run: () => { const next = bosses[(bosses.findIndex((boss) => boss.key === selectedKey) + 1) % bosses.length]; if (next) void choose(next); }, enabled: !preparing && bosses.length > 0 },
|
||||
...roles.map((value) => ({ id: `boss-testing-role-${value}`, run: () => setRole(value), enabled: !preparing })),
|
||||
{ id: "boss-testing-start", run: testSelected, enabled: Boolean(selected) && !preparing },
|
||||
{ id: "boss-testing-random", run: random, enabled: bosses.length > 0 && !preparing },
|
||||
];
|
||||
const controller = useMenuController(actions, { initialId: "boss-testing-picker", onBack: onClose });
|
||||
const selectProps = (controlId: string) => ({ className: controller.selectedId === controlId ? "is-controller-selected" : "",
|
||||
onPointerEnter: () => controller.select(controlId), disabled: preparing });
|
||||
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
|
||||
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
|
||||
return <DualDisplayFrame contextLabel="Boss Testing" top={
|
||||
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
|
||||
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
|
||||
{button("boss-testing-back", "Back to main menu", onClose)}</header>
|
||||
<main className="boss-testing-main">
|
||||
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
|
||||
<div className="boss-testing-filters">
|
||||
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
|
||||
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
<option value="all">All games</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
|
||||
<label>Readiness<select {...selectProps("boss-testing-status")} value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="all">All statuses</option>{Object.entries(BOSS_TESTING_STATUS_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="boss-testing-picker">Choose a boss <small>{bosses.length} bosses</small>
|
||||
<select {...selectProps("boss-testing-picker")} value={bosses.some((boss) => boss.key === selectedKey) ? selectedKey : ""}
|
||||
onChange={(event) => { const boss = bosses.find((entry) => entry.key === event.target.value); if (boss) void choose(boss); }}>
|
||||
<option value="" disabled>Select a boss…</option>
|
||||
{bosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.dungeonTitle} · {BOSS_TESTING_STATUS_LABELS[reviews[boss.key]?.status ?? "needs-testing"]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{selected && <article className="boss-testing-selected"><p className="front-kicker">Selected encounter</p><h2>{selected.name}</h2><p>{selected.dungeonTitle}</p>
|
||||
<strong className={`boss-testing-status boss-testing-status--${reviews[selected.key]?.status ?? "needs-testing"}`}>{BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]}</strong>
|
||||
{reviews[selected.key]?.pending && <small>Readiness saved on this device; waiting to sync.</small>}
|
||||
</article>}
|
||||
{roles.length > 1 && <div className="boss-testing-actions" aria-label="Your party role">{roles.map((value) =>
|
||||
<ControllerButton key={value} controlId={`boss-testing-role-${value}`} selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" aria-pressed={effectiveRole === value} onClick={() => setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]}</ControllerButton>
|
||||
)}</div>}
|
||||
{preparing && <div role="status" className="boss-testing-progress"><p>Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}</p>
|
||||
{content.installingPackId && <><progress max={Math.max(1, content.totalBytes)} value={content.downloadedBytes} /><small>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB</small></>}
|
||||
</div>}
|
||||
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
|
||||
{!bosses.length && <p role="status">No bosses match these filters.</p>}
|
||||
<div className="boss-testing-actions">{button("boss-testing-start", error ? "Retry and test selected boss" : "Test selected boss", testSelected, !selected || preparing)}
|
||||
{button("boss-testing-random", "Fight a random boss", random, !bosses.length || preparing)}</div>
|
||||
<p className="boss-testing-help">Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.</p>
|
||||
</main><ControllerLegend back />
|
||||
</FrontSurface>
|
||||
} bottom={<LazyLocalCompanionPanel />} />;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { DungeonCompanionHud } from "./DungeonCompanionHud";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
<p className="companion-kicker">Ready on both screens</p>
|
||||
<h2>One roster. Two ways to play.</h2>
|
||||
<div className="companion-feature-grid">
|
||||
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
|
||||
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
|
||||
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
|
||||
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
|
||||
</div>
|
||||
</main>
|
||||
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
|
||||
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
return <CompanionDungeonSelection selected={selected} send={send} />;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "class-hall") {
|
||||
const definition = selected ? classById(selected.classId) : null;
|
||||
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
|
||||
<header><BrandMark compact /><b>CLASS HALL</b></header>
|
||||
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
|
||||
<h2>{definition?.name ?? "Choose your path"}</h2>
|
||||
<p>Choose your specialization on the top screen.</p>
|
||||
<p>Your specialization determines your role and starting equipment.</p>
|
||||
</section></main>
|
||||
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
|
||||
</FrontSurface>;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "manastorms") {
|
||||
return <ShelvedCompanionPanel selected={selected} send={send} />;
|
||||
}
|
||||
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
}
|
||||
|
||||
export function LocalCompanionPanel() {
|
||||
useShellStore();
|
||||
const shell = useShellStore();
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { ActionIcon, type ActionBarItem } from "./ActionBar";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
|
||||
const ui = useUiLayoutStore();
|
||||
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
{bar.slots.map((button, s) => {
|
||||
const item = button.abilityId ? items[button.abilityId] : null;
|
||||
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
|
||||
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
|
||||
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
|
||||
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
|
||||
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
|
||||
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
|
||||
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
|
||||
{editing && !item && <small>{s + 1}</small>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>
|
||||
</EditableHudElement>; })}</>;
|
||||
|
||||
+3
-1
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
|
||||
export function DpsMeter() {
|
||||
const { surface } = useHudSurface();
|
||||
useDpsMeterStore();
|
||||
const ui = useUiLayoutStore();
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null;
|
||||
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Hud } from "./Hud";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { CompanionCommand } from "../platform/dualScreenSync";
|
||||
|
||||
export function DungeonCompanionHud({ send, remote = false, completion, interaction }: {
|
||||
send: (command: CompanionCommand) => void;
|
||||
remote?: boolean;
|
||||
completion?: boolean;
|
||||
interaction?: { prompt: string; name: string; label: string } | null;
|
||||
}) {
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
return <main className="game-shell companion-hud-shell" aria-label="Dungeon bottom screen">
|
||||
<Hud surface="bottom" completion={completion} interaction={interaction}
|
||||
onFinish={remote ? () => send({ name: "finishDungeon" }) : undefined}
|
||||
onInteract={remote ? () => send({ name: "interactDungeonObject" }) : undefined}
|
||||
onResurrect={remote ? (checkpointId) => send({ name: "resurrectParty", checkpointId }) : undefined}
|
||||
onLayoutChange={remote ? (id, update) => send({ name: "editHudElement", id, update }) : undefined} />
|
||||
<nav className="companion-hud-menus" aria-label="Dungeon menus">
|
||||
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "map" })}>Map</button>
|
||||
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "spellbook" })}>Spells</button>
|
||||
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "inventory" })}>Gear</button>
|
||||
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "ui-layout" })}>Edit UI</button>
|
||||
<button type="button" onClick={() => send({ name: "togglePause" })}>Pause</button>
|
||||
{bossTest && <button type="button" onClick={() => send({ name: "openOverlay", overlay: "boss-test-review" })}>Boss review</button>}
|
||||
</nav>
|
||||
</main>;
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
|
||||
export function DungeonInteractionPrompt() {
|
||||
export function DungeonInteractionPrompt({ interaction, onInteract }: {
|
||||
interaction?: { prompt: string; name: string; label: string } | null;
|
||||
onInteract?: () => void;
|
||||
} = {}) {
|
||||
const label = useInputBindingLabels();
|
||||
const playerPosition = useGameStore((state) => state.playerPosition);
|
||||
const inputMode = useGameStore((state) => state.inputMode);
|
||||
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
|
||||
activeDungeonEncounterRevision,
|
||||
);
|
||||
void playerPosition;
|
||||
const candidate = overlay ? null : activeDungeonInteractionCandidate();
|
||||
const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null;
|
||||
const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null);
|
||||
if (!candidate) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="dungeon-interaction-prompt"
|
||||
onClick={() => interactWithActiveDungeonObject()}
|
||||
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
|
||||
onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
|
||||
aria-label={`${candidate.prompt}: ${candidate.name}`}
|
||||
>
|
||||
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
|
||||
<span>
|
||||
<strong>{candidate.object.interaction!.prompt}</strong>
|
||||
<small>{candidate.object.name} · {candidate.state.label}</small>
|
||||
<strong>{candidate.prompt}</strong>
|
||||
<small>{candidate.name} · {candidate.label}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
export function DungeonIssueReportPanel() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const [comment, setComment] = useState("");
|
||||
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const back = () => { if (!busy.current) openOverlay("pause"); };
|
||||
const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); };
|
||||
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "report-back", run: back, enabled: !submitting },
|
||||
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
|
||||
], [canSubmit, openOverlay, submitted, submitting]);
|
||||
], [bossTest, canSubmit, openOverlay, submitted, submitting]);
|
||||
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitDungeonReport(session, dungeonId, comment);
|
||||
await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
|
||||
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
|
||||
setSubmitted(true);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
|
||||
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
|
||||
<p className="eyebrow">Dungeon feedback</p>
|
||||
<h2 id="issue-report-title">Report an issue</h2>
|
||||
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
<p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
|
||||
<h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
|
||||
<p>{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
|
||||
<form ref={form} onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
|
||||
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
|
||||
</form>
|
||||
)}
|
||||
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
|
||||
className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}</ControllerButton>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
|
||||
return (
|
||||
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
|
||||
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
|
||||
{report.bossName && <strong>Boss: {report.bossName}</strong>}
|
||||
<p className="dungeon-report-comment">{report.comment}</p>
|
||||
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
|
||||
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./uiLayout.css";
|
||||
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const surface = useHudSurface();
|
||||
const dualScreen = currentDisplayLayout() !== "single";
|
||||
const editing = editingOverride ?? overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = elementLayout ?? layout.elements[id];
|
||||
const screen = resolveHudScreen(id, layout, dualScreen);
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
|
||||
const positioned = editing || element.position !== null;
|
||||
const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id));
|
||||
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
|
||||
const position = element.position ?? (bottomAnchor !== undefined
|
||||
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host, hostSelector]);
|
||||
}, [positioned, host, hostSelector, screen]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => {
|
||||
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
|
||||
if (onChange) onChange(update);
|
||||
else if (surface.onChange) surface.onChange(id, update);
|
||||
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
|
||||
};
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
const content = <div ref={ref} data-hud-element={id} style={style}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${state.selectedElement === id ? "editable-hud--selected" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
|
||||
{children}
|
||||
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
|
||||
@@ -102,5 +108,6 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
|
||||
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
|
||||
</div>;
|
||||
if (dualScreen && screen !== surface.surface) return null;
|
||||
return positioned && host ? createPortal(content, host) : content;
|
||||
}
|
||||
+27
-9
@@ -1,4 +1,7 @@
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { HudSurfaceContext } from "./HudSurface";
|
||||
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
|
||||
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -168,7 +171,15 @@ export function DungeonCompletePrompt({
|
||||
);
|
||||
}
|
||||
|
||||
export function Hud() {
|
||||
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
|
||||
surface?: "top" | "bottom";
|
||||
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
|
||||
completion?: boolean;
|
||||
onFinish?: () => void;
|
||||
onResurrect?: (checkpointId: string) => void;
|
||||
interaction?: { prompt: string; name: string; label: string } | null;
|
||||
onInteract?: () => void;
|
||||
} = {}) {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const ui = useUiLayoutStore();
|
||||
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
||||
@@ -230,7 +241,7 @@ export function Hud() {
|
||||
);
|
||||
const runProgress = useDungeonRunStore((state) => state.progress);
|
||||
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
|
||||
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
|
||||
const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 100);
|
||||
@@ -282,6 +293,10 @@ export function Hud() {
|
||||
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
|
||||
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
|
||||
target: ability.target,
|
||||
cooldownSeconds: ability.cooldownMs / 1_000,
|
||||
castSeconds: ability.castTimeMs / 1_000,
|
||||
range: ability.range.max,
|
||||
talentLocked,
|
||||
};
|
||||
return [catalogAbility.id, item];
|
||||
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
@@ -391,12 +406,14 @@ export function Hud() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
|
||||
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
{surface === "top" && <BossLootWindow />}
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
|
||||
<EditableHudElement id="completion">{dungeonComplete ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
|
||||
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
|
||||
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
|
||||
) : null}</EditableHudElement>
|
||||
@@ -409,7 +426,7 @@ export function Hud() {
|
||||
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
|
||||
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
|
||||
|
||||
{pendingGroundTarget ? (
|
||||
{surface === "top" && pendingGroundTarget ? (
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
<div aria-hidden="true"><i /><b /></div>
|
||||
<strong>Place ground effect</strong>
|
||||
@@ -507,7 +524,7 @@ export function Hud() {
|
||||
items={actionItems}
|
||||
activeLayer={actionLayer}
|
||||
onActivate={castBinding}
|
||||
inputMode={inputMode}
|
||||
inputMode={surface === "bottom" ? "gamepad" : inputMode}
|
||||
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
|
||||
/></EditableHudElement>}
|
||||
</div>
|
||||
@@ -527,8 +544,8 @@ export function Hud() {
|
||||
].map((asset) => asset.fileName).join(" and ")}.`}</span>
|
||||
</div>
|
||||
)}</EditableHudElement>
|
||||
<LoadingOverlay />
|
||||
{health <= 0 ? (
|
||||
{surface === "top" && <LoadingOverlay />}
|
||||
{surface === "top" && health <= 0 ? (
|
||||
<DefeatedPrompt
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
@@ -539,5 +556,6 @@ export function Hud() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</HudSurfaceContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
|
||||
|
||||
export const HudSurfaceContext = createContext<{
|
||||
surface: "top" | "bottom";
|
||||
onChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
|
||||
onResurrect?: (checkpointId: string) => void;
|
||||
}>({ surface: "top" });
|
||||
export const useHudSurface = () => useContext(HudSurfaceContext);
|
||||
@@ -26,7 +26,7 @@ const PC_KEYS = [
|
||||
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
|
||||
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
|
||||
];
|
||||
function KeybindsDialog() {
|
||||
function KeybindsDialog({ onClose }: { onClose?: () => void }) {
|
||||
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
|
||||
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -57,7 +57,7 @@ function KeybindsDialog() {
|
||||
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
|
||||
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
|
||||
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
|
||||
const close = () => useGameStore.getState().openOverlay("options");
|
||||
const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
|
||||
const endCapture = () => { setListening(false); setCapturingInput(false); };
|
||||
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
|
||||
const apply = (id: string, key: string | null) => {
|
||||
@@ -211,7 +211,7 @@ function KeybindsDialog() {
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
export function KeybindsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "keybinds");
|
||||
return open ? <KeybindsDialog /> : null;
|
||||
export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "keybinds");
|
||||
return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { beginMainMenuCharacterEditing } from "../app/mainMenuCharacterEditing";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { KeybindsPanel } from "./KeybindsPanel";
|
||||
import { RomSkillsPanel } from "./RomSkillsPanel";
|
||||
import { loadTalentGameplayPanel } from "./talentGameplayResources";
|
||||
import "./mainMenuCharacterEditor.css";
|
||||
|
||||
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
|
||||
export type MainMenuEditor = "talents" | "keybinds" | "bindings";
|
||||
|
||||
export function MainMenuCharacterEditor({ editor, onClose }: {
|
||||
editor: MainMenuEditor;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const ownerId = useShellStore((state) => state.session?.ownerId);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const characterKey = `${ownerId ?? ""}:${character?.id ?? ""}`;
|
||||
const [readyKey, setReadyKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stop = beginMainMenuCharacterEditing();
|
||||
setReadyKey(stop ? characterKey : null);
|
||||
return () => stop?.();
|
||||
}, [characterKey]);
|
||||
|
||||
const ready = readyKey === characterKey;
|
||||
return <DualDisplayFrame companionDisabled contextLabel="Character setup" bottom={null} top={
|
||||
<FrontSurface className="main-menu-character-editor" ariaLabel="Main menu character setup">
|
||||
<header className="main-menu-character-editor__header">
|
||||
<BrandMark compact />
|
||||
<div><strong>{character?.name ?? "Character setup"}</strong>
|
||||
<span>{character ? `Level ${character.level} ${classById(character.classId).name}` : "Select a character to edit."}</span></div>
|
||||
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
|
||||
</header>
|
||||
{notice && <p className="front-notice" role="status">{notice}</p>}
|
||||
<div className="main-menu-character-editor__panel">
|
||||
{!ready ? <p role="status">Preparing character…</p> : <Suspense fallback={<p role="status">Opening talents…</p>}>
|
||||
{editor === "talents" && (character?.categoryId === "rom"
|
||||
? <RomSkillsPanel open onClose={onClose} />
|
||||
: <TalentGameplayPanel open onClose={onClose} />)}
|
||||
{editor === "keybinds" && <KeybindsPanel open onClose={onClose} />}
|
||||
{editor === "bindings" && <AbilityBindingsPanel open onClose={onClose} />}
|
||||
</Suspense>}
|
||||
</div>
|
||||
</FrontSurface>
|
||||
} />;
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
|
||||
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
|
||||
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
|
||||
const [contentManagerOpen, setContentManagerOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
const [cardsOpen, setCardsOpen] = useState(false);
|
||||
const [bossTestingOpen, setBossTestingOpen] = useState(false);
|
||||
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
|
||||
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
|
||||
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
|
||||
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
readonly onOpenCards: () => void;
|
||||
readonly onOpenBossTesting: () => void;
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
run: returnToCharacters,
|
||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||
},
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(character ? [
|
||||
{ id: "mode-class-hall", run: openClassHall },
|
||||
{ id: "mode-talents", run: () => onOpenEditor("talents") },
|
||||
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
|
||||
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
|
||||
] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-boss-testing", run: onOpenBossTesting },
|
||||
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
|
||||
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
{character && <>
|
||||
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
|
||||
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
|
||||
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
|
||||
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
</>}
|
||||
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
|
||||
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
|
||||
const capped = !selected
|
||||
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections;
|
||||
return (
|
||||
<AbilityTooltipTarget key={spell.id} ability={spell}>
|
||||
<ControllerButton
|
||||
key={spell.id}
|
||||
controlId={`manastorm-spell-${spell.id}`}
|
||||
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
|
||||
select={controller.select}
|
||||
className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
|
||||
type="button"
|
||||
disabled={capped}
|
||||
onClick={() => toggleSpell(spell.id)}
|
||||
aria-disabled={capped}
|
||||
onClick={() => { if (!capped) toggleSpell(spell.id); }}
|
||||
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
|
||||
>
|
||||
<img src={spell.icon} alt="" aria-hidden="true" />
|
||||
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
|
||||
<small>Spell {spell.dbcSpellId}</small>
|
||||
</span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
})}
|
||||
{!spellLoadoutModel?.options.length ? (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
|
||||
|
||||
/** Compact roster for the player plus the selected number of targetable AI allies. */
|
||||
export function PartyFrames() {
|
||||
const { onResurrect } = useHudSurface();
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const playerHealth = useCombatStore((state) => state.health);
|
||||
@@ -342,7 +344,7 @@ export function PartyFrames() {
|
||||
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
||||
? "Only the party's simulation host can resurrect NPC teammates."
|
||||
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
||||
onClick={() => resurrectDeadPartyMembers(checkpointId)}
|
||||
onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
|
||||
>
|
||||
<strong>Rez dead members</strong>
|
||||
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
|
||||
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -14,6 +15,7 @@ function PauseDialog() {
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const reset = useGameStore((state) => state.resetAtEntrance);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
@@ -50,12 +52,13 @@ function PauseDialog() {
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
|
||||
...(bossTest ? [{ id: "pause-boss-testing-review", run: () => openOverlay("boss-test-review") }] : []),
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
: [{ id: "pause-entrance", run: returnToEntrance }]),
|
||||
{ id: "pause-main-menu", run: leaveDungeon },
|
||||
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
|
||||
], [abandonManastormRun, bossTest, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
|
||||
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
|
||||
|
||||
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
|
||||
@@ -73,10 +76,11 @@ function PauseDialog() {
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
|
||||
<p className="eyebrow">Healer Man paused</p>
|
||||
<h2 id="pause-title">The cavern waits.</h2>
|
||||
<h2 id="pause-title">{bossTest ? `${bossTest.name} — Boss Testing` : "The cavern waits."}</h2>
|
||||
<p>Resume play or open any character and system menu from this hub.</p>
|
||||
<div className="pause-menu__actions">
|
||||
{menuButton("pause-resume", "Resume expedition", resume, true)}
|
||||
{bossTest && menuButton("pause-boss-testing-review", "Boss readiness & report", () => openOverlay("boss-test-review"))}
|
||||
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
|
||||
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
|
||||
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
|
||||
@@ -92,7 +96,7 @@ function PauseDialog() {
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
|
||||
: menuButton("pause-entrance", bossTest ? "Restart boss test" : "Return to entrance", returnToEntrance)}
|
||||
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
|
||||
</div>
|
||||
<dl className="controls-list">
|
||||
@@ -110,5 +114,6 @@ function PauseDialog() {
|
||||
export function PauseMenu() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
|
||||
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
|
||||
return overlay === "pause" ? <PauseDialog /> : null;
|
||||
}
|
||||
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
|
||||
return "primary";
|
||||
}
|
||||
|
||||
export function RomSkillsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "talents");
|
||||
const close = useGameStore((state) => state.closeOverlay);
|
||||
export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "talents");
|
||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||
const close = onClose ?? closeOverlay;
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
|
||||
{ id: "rom-skills-close", run: close },
|
||||
], [abilities, close]);
|
||||
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close });
|
||||
if (!open) return null;
|
||||
if (!(open ?? storeOpen)) return null;
|
||||
|
||||
const primary = classById(classId);
|
||||
const secondary = secondaryClassId ? classById(secondaryClassId) : null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
export interface SpellbookAbilityView {
|
||||
id: string;
|
||||
@@ -149,6 +150,10 @@ function SpellbookDialog({
|
||||
? "Learn in talents"
|
||||
: `Unlocks at level ${ability.level}`;
|
||||
return (
|
||||
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
|
||||
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
|
||||
talentLocked: ability.talentLocked && !ability.known,
|
||||
}}>
|
||||
<ControllerButton
|
||||
key={ability.id}
|
||||
controlId={`spell-${ability.id}`}
|
||||
@@ -184,6 +189,7 @@ function SpellbookDialog({
|
||||
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
|
||||
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
})}
|
||||
{!visibleAbilities.length && (
|
||||
|
||||
@@ -13,7 +13,11 @@ function treeSigil(name: string): string {
|
||||
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
|
||||
export default function TalentGameplayPanel({ readOnly = false, open, onClose }: {
|
||||
readOnly?: boolean;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
} = {}) {
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
@@ -45,5 +49,7 @@ export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: b
|
||||
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
|
||||
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
|
||||
readOnly={readOnly}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
/>;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ function TalentsDialog({
|
||||
<><strong>{availablePoints}</strong><span>points available</span></>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
|
||||
</header>
|
||||
|
||||
<p className="talents-panel__intro">
|
||||
@@ -436,7 +436,7 @@ function TalentsDialog({
|
||||
>Reset talents</ControllerButton>
|
||||
</div>
|
||||
<footer className="game-panel__footer talents-panel__footer">
|
||||
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Pause menu</span>
|
||||
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+38
-12
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
||||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||||
resolveHudScreen, type HudScreen,
|
||||
} from "../game/uiLayout";
|
||||
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
|
||||
import { profileInputBindings } from "../game/keybindPreferences";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
|
||||
export function UiLayoutEditor() {
|
||||
const open = useGameStore((state) => state.overlay === "ui-layout");
|
||||
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const cloudSave = useCloudSaveStatus();
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const [tab, setTab] = useState<UiDevice>(ui.device);
|
||||
const [name, setName] = useState("My UI");
|
||||
const [slot, setSlot] = useState(0);
|
||||
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
|
||||
useUiLayoutStore.getState().begin();
|
||||
setTab(useUiLayoutStore.getState().device); setMessage("");
|
||||
}
|
||||
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
}, [open, ui.draft !== null]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
|
||||
}, [open]);
|
||||
if (!open || !ui.draft) return null;
|
||||
const draft = ui.draft;
|
||||
const dualScreen = currentDisplayLayout() !== "single";
|
||||
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
|
||||
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
|
||||
const validName = name.trim().length > 0;
|
||||
@@ -87,7 +93,7 @@ export function UiLayoutEditor() {
|
||||
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
|
||||
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
|
||||
{!collapsed && <div className="ui-layout-editor__body">
|
||||
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
|
||||
<p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
|
||||
<details open><summary>UI templates · 6 slots</summary>
|
||||
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
|
||||
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
|
||||
@@ -131,20 +137,30 @@ export function UiLayoutEditor() {
|
||||
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
|
||||
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
|
||||
</details>
|
||||
<details><summary>Elements · move / hide</summary>
|
||||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
|
||||
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
|
||||
<details open={dualScreen}><summary>Elements · screen / move / hide</summary>
|
||||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
|
||||
onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
|
||||
<label>{label}</label>
|
||||
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
|
||||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
|
||||
} }))}>
|
||||
{!dualScreen && <option value="auto">Automatic</option>}
|
||||
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
|
||||
</select>
|
||||
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
|
||||
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
|
||||
</details>
|
||||
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<details className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
|
||||
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
|
||||
}))} />Show DPS meter</label>
|
||||
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
|
||||
}))}>
|
||||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||
@@ -173,7 +189,17 @@ export function UiLayoutEditor() {
|
||||
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
|
||||
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
||||
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
|
||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
|
||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||||
const ability = abilityAtLevel(definition, level, talentRank);
|
||||
return <AbilityTooltipTarget key={ability.id} ability={{
|
||||
name: ability.name, description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
|
||||
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||||
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
|
||||
})}</div>
|
||||
</div>}
|
||||
</>}
|
||||
</details>}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.boss-testing-screen {
|
||||
--boss-test-border: rgba(177, 199, 218, 0.22);
|
||||
--boss-test-panel: rgba(14, 23, 33, 0.76);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
padding: clamp(16px, 3vw, 40px);
|
||||
gap: 20px;
|
||||
}
|
||||
.boss-testing-header { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
|
||||
.boss-testing-header > div:nth-child(2) { flex: 1; }
|
||||
.boss-testing-header h1, .boss-testing-selected h2 { margin: 5px 0; }
|
||||
.boss-testing-header span, .boss-testing-help { color: var(--front-muted, #b1bbc5); }
|
||||
.boss-testing-main { width: min(100%, 1060px); margin: 0 auto; display: grid; gap: 18px; }
|
||||
.boss-testing-main p { margin: 0; line-height: 1.6; }
|
||||
.boss-testing-filters { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
|
||||
.boss-testing-main label { display: grid; gap: 8px; font-weight: 600; min-width: 0; }
|
||||
.boss-testing-main input, .boss-testing-main select {
|
||||
color: #e8edf2;
|
||||
background: #17232f;
|
||||
border: 1px solid var(--boss-test-border);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.boss-testing-main select option { background: #17232f; }
|
||||
.boss-testing-selected { background: var(--boss-test-panel); border: 1px solid var(--boss-test-border); border-radius: 12px; padding: 20px; display: grid; gap: 8px; }
|
||||
.boss-testing-status { width: fit-content; padding: 5px 10px; border-radius: 5px; background: #354454; }
|
||||
.boss-testing-status--ready { color: #b6f0cd; }
|
||||
.boss-testing-status--needs-testing { color: #f4db9e; }
|
||||
.boss-testing-status--not-ready { color: #f7b7b7; }
|
||||
.boss-testing-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.boss-testing-actions [aria-pressed="true"] { border-color: #d6b66b; box-shadow: inset 0 0 0 1px #d6b66b; }
|
||||
.boss-testing-progress { display: grid; gap: 8px; }
|
||||
.boss-testing-progress progress { width: 100%; accent-color: #d6b66b; }
|
||||
.boss-testing-help { font-size: 0.9rem; }
|
||||
.boss-testing-review .boss-testing-actions { margin: 18px 0; }
|
||||
@media (max-width: 700px) {
|
||||
.boss-testing-filters { grid-template-columns: 1fr 1fr; }
|
||||
.boss-testing-filters label:first-child { grid-column: 1 / -1; }
|
||||
.boss-testing-header .front-brand { display: none; }
|
||||
.boss-testing-header { gap: 12px; }
|
||||
.boss-testing-actions > button { flex: 1 1 180px; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.main-menu-character-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.main-menu-character-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--edge);
|
||||
}
|
||||
.main-menu-character-editor__header > div:nth-child(2) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.main-menu-character-editor__header strong { color: var(--gold); }
|
||||
.main-menu-character-editor__header span { color: var(--muted); font-size: 12px; }
|
||||
.main-menu-character-editor__panel { position: relative; flex: 1; min-height: 0; }
|
||||
.main-menu-character-editor__panel .modal-backdrop {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.main-menu-character-editor__panel .game-panel {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.main-menu-character-editor__panel .keybinds-panel { width: min(1100px, 100%); height: 100%; }
|
||||
@media (max-width: 640px), (max-height: 500px) {
|
||||
.main-menu-character-editor { gap: 6px; }
|
||||
.main-menu-character-editor__header { gap: 10px; padding-bottom: 6px; }
|
||||
.main-menu-character-editor__header .front-brand { display: none; }
|
||||
.main-menu-character-editor__header .front-button { padding: 8px 12px; font-size: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
|
||||
import { KeybindsPanel } from "./KeybindsPanel";
|
||||
import { RomSkillsPanel } from "./RomSkillsPanel";
|
||||
|
||||
const editors: {
|
||||
Panel: ComponentType<{ open?: boolean; onClose?: () => void }>;
|
||||
title: string;
|
||||
close: string;
|
||||
}[] = [
|
||||
{ Panel: AbilityBindingsPanel, title: "Ability controls", close: "Close button assignments" },
|
||||
{ Panel: KeybindsPanel, title: "Keybinds", close: "Close keybinds" },
|
||||
{ Panel: RomSkillsPanel, title: "Skills", close: ">Close</button>" },
|
||||
];
|
||||
|
||||
describe.each(editors)("standalone main-menu editor: $title", ({ Panel, title, close }) => {
|
||||
it("can open without changing the gameplay overlay", () => {
|
||||
const markup = renderToStaticMarkup(createElement(Panel, { open: true, onClose: () => undefined }));
|
||||
expect(markup).toContain('role="dialog"');
|
||||
expect(markup).toContain(title);
|
||||
expect(markup).toContain(close);
|
||||
});
|
||||
|
||||
it("respects an explicit closed state", () => {
|
||||
expect(renderToStaticMarkup(createElement(Panel, { open: false }))).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/* Logical sizing uses a common physical density for both panels (160 px/in). */
|
||||
.thor-preview-frame { min-height: 0; }
|
||||
.thor-preview-label, .thor-preview-hinge { width: 100%; }
|
||||
.thor-preview-top, .thor-preview-bottom, .dedicated-display-surface, .bottom-display-root { container-type: size; }
|
||||
.companion-hud-shell { position: relative; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(ellipse at top, #1c3827, #07110d 70%); }
|
||||
.companion-hud-shell .hud-top-right { display: contents; }
|
||||
.companion-hud-shell .player-status-panel { position: absolute; top: 12px; left: 12px; width: calc(100% - 24px); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.companion-hud-shell .player-status-panel__section { padding: 8px; }
|
||||
.companion-hud-shell .player-status-panel__section > header, .companion-hud-shell .player-status-panel__empty { font-size: 12px; }
|
||||
.companion-hud-shell .player-status-panel__section > header b { min-width: 22px; height: 22px; font-size: 12px; }
|
||||
.companion-hud-shell .player-status-panel__icons { justify-content: flex-start; min-height: 44px; }
|
||||
.companion-hud-shell .player-status-panel :is(.aura, .timed-effect) { width: 44px; height: 44px; }
|
||||
.companion-hud-shell :is(.aura__timer, .timed-effect__timer) { font-size: 11px; }
|
||||
.companion-hud-shell .player-status-tooltip { top: 100%; left: 0; width: 100%; font-size: 13px; }
|
||||
.companion-hud-shell .player-status-tooltip > header strong { font-size: 14px; }
|
||||
.companion-hud-shell .player-status-tooltip p { font-size: 13px; }
|
||||
.companion-hud-shell .player-status-tooltip :is(footer, header > span) { font-size: 11px; }
|
||||
.companion-hud-shell .hud-bottom-stack { bottom: 76px; width: calc(100% - 16px); }
|
||||
.companion-hud-shell .hud-bottom-stack .action-bar { --action-slot-size: 44px; width: min(420px, 100%); margin-top: 12px; }
|
||||
.companion-hud-shell .action-slot__binding { min-width: 18px; height: 18px; font-size: 10px; }
|
||||
.companion-hud-shell .action-bar__layer-status span { font-size: 11px; }
|
||||
.companion-hud-shell .action-bar__detail strong { font-size: 12px; }
|
||||
.companion-hud-shell .action-bar__detail span { font-size: 10px; }
|
||||
.companion-hud-shell .action-slot__target { font-size: 10px; }
|
||||
.companion-hud-shell .party-frame { min-height: 48px; }
|
||||
.companion-hud-shell .party-frame__identity strong { font-size: 13px; }
|
||||
.companion-hud-shell .party-frame__identity small { font-size: 10px; }
|
||||
.companion-hud-shell .party-frame__health { height: 14px; }
|
||||
.companion-hud-shell .party-frame__health b, .companion-hud-shell .party-frames > header { font-size: 10px; }
|
||||
.companion-hud-shell .party-command-bar button { min-height: 44px; padding-inline: 2px; }
|
||||
.companion-hud-shell .party-command-bar strong { font-size: 11px; }
|
||||
.companion-hud-shell .party-command-bar kbd { display: none; }
|
||||
.companion-hud-shell .hud-minimap__caption strong { font-size: 13px; }
|
||||
.companion-hud-shell .hud-minimap__caption span, .companion-hud-shell .target-frame small { font-size: 11px; }
|
||||
.companion-hud-shell .dps-meter { position: absolute; top: 116px; left: 12px; bottom: auto; width: min(var(--dps-meter-width, 230px), calc(100% - 24px)); max-height: 64px; font-size: 12px; }
|
||||
.companion-hud-shell .experience-bar { bottom: 56px; left: 12px; right: 12px; width: calc(100% - 24px); height: 16px; }
|
||||
.companion-hud-shell .experience-bar__label { font-size: 10px; }
|
||||
.companion-hud-menus { position: absolute; bottom: max(6px, env(safe-area-inset-bottom)); left: 8px; right: 8px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; pointer-events: auto; z-index: 30; }
|
||||
.companion-hud-menus button { min-width: 0; min-height: 44px; padding: 4px; border: 1px solid var(--edge); border-radius: 6px; background: #172b20; color: var(--ink); font-size: 13px; }
|
||||
.ui-layout-editor { font-size: 14px; }
|
||||
.ui-layout-editor__body { min-height: 0; }
|
||||
.ui-layout-editor :is(button, select, input:not([type=checkbox])) { min-height: 44px; }
|
||||
.ui-layout-editor > header button { min-width: 44px; }
|
||||
.ui-layout-editor__element { flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--edge); }
|
||||
.ui-layout-editor__element label { flex: 1 1 120px; margin: 0; }
|
||||
.ui-layout-editor__element select { min-width: 78px; }
|
||||
.editable-hud__handle { min-width: 44px; min-height: 44px; padding: 6px 8px; font-size: 12px; }
|
||||
.editable-hud--editing { min-height: 44px; }
|
||||
.editable-hud--editing.editable-hud--selected { z-index: 26; outline: 2px solid var(--gold); }
|
||||
.companion-hud-shell .editable-hud--editing.editable-hud--selected { z-index: 40; }
|
||||
.companion-hud-shell .dungeon-complete-banner { flex-wrap: wrap; }
|
||||
.companion-hud-shell .dungeon-complete-banner p { flex-basis: calc(100% - 42px); }
|
||||
.companion-hud-shell .dungeon-complete-banner .button { flex: 1; min-height: 44px; font-size: 13px; }
|
||||
.companion-hud-shell .dungeon-complete-banner strong { font-size: 14px; }
|
||||
.companion-hud-shell .dungeon-complete-banner small { font-size: 12px; }
|
||||
|
||||
/* Container queries also work in the stacked preview: the window is not a panel. */
|
||||
@container (max-width: 650px) {
|
||||
.front-surface--bottom { padding: 10px; font-size: 13px; overflow-y: auto; }
|
||||
.companion-panel { grid-template-rows: 44px minmax(0, 1fr) auto; }
|
||||
.companion-panel > header > b { font-size: 11px; }
|
||||
.companion-panel .front-brand { gap: 6px; }
|
||||
.companion-panel .front-brand small { font-size: 8px; }
|
||||
.companion-panel .front-brand strong { font-size: 15px; }
|
||||
.companion-panel > main { min-height: 0; overflow: auto; padding: 8px 0; gap: 8px; }
|
||||
.companion-roster > main { grid-template-columns: minmax(0, 1fr); }
|
||||
.companion-selection-card { display: none; }
|
||||
.companion-mini-roster { overflow: auto; }
|
||||
.companion-mini-roster button { min-height: 48px; padding: 8px; }
|
||||
.companion-mini-roster button strong { font-size: 14px; }
|
||||
.companion-mini-roster button small { font-size: 11px; }
|
||||
.companion-actions { flex-wrap: wrap; padding-top: 6px; }
|
||||
.companion-actions button { min-height: 44px; min-width: 100px; font-size: 12px; }
|
||||
.companion-dungeons > main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.companion-dungeon-role { display: block; }
|
||||
.companion-dungeon-role .character-preview { display: none; }
|
||||
.companion-dungeon-role h2 { font-size: 17px; }
|
||||
.companion-role-options { display: flex; gap: 6px; }
|
||||
.companion-role-options button { flex: 1; min-height: 44px; padding: 6px; }
|
||||
.companion-role-options strong { font-size: 14px; }
|
||||
.companion-role-options small { font-size: 10px; }
|
||||
.companion-category-tabs button { min-height: 44px; }
|
||||
.companion-category-tabs button strong { font-size: 13px; }
|
||||
.companion-create > main { grid-template-columns: 55% minmax(0, 1fr); }
|
||||
.companion-create main > .character-preview { min-width: 0; height: 240px; }
|
||||
.companion-create h2 { font-size: 24px; }
|
||||
.companion-create dl { font-size: 14px; }
|
||||
.companion-panel dl dt, .companion-panel dl dd { font-size: 13px; }
|
||||
.companion-feature-grid article strong { font-size: 13px; }
|
||||
.companion-feature-grid article small { font-size: 11px; }
|
||||
.companion-login > footer { font-size: 11px; }
|
||||
}
|
||||
|
||||
@container (min-width: 700px) and (max-width: 1000px) {
|
||||
.front-surface--top { padding: 12px; }
|
||||
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
|
||||
.front-surface--top button { min-height: 44px; }
|
||||
.login-hero h1 { font-size: 44px; }
|
||||
.login-form { padding: 20px; }
|
||||
.login-form h2 { font-size: 25px; }
|
||||
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
|
||||
.roster-details { display: none; }
|
||||
.roster-stage-continue { display: flex; }
|
||||
.character-create-screen { grid-template-columns: 42% 58%; grid-template-rows: 47px 3px minmax(0, 1fr) 58px; }
|
||||
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 3; }
|
||||
.creator-races { grid-column: 1; }
|
||||
.creator-preview-stage, .appearance-preview-stage { display: none; }
|
||||
.creator-classes { grid-column: 2; }
|
||||
.appearance-controls { grid-column: 1 / 3; }
|
||||
.creator-footer { gap: 8px; }
|
||||
.dungeon-select-screen { grid-template-columns: 260px minmax(0, 1fr); gap: 10px; }
|
||||
.dungeon-select-detail { grid-template-columns: 1fr; gap: 10px; }
|
||||
.dungeon-select-map { display: none; }
|
||||
.dungeon-select-copy { padding: 8px; }
|
||||
.dungeon-role-option { min-height: 48px; }
|
||||
.front-surface--top :is(.race-option > span, .class-option strong, .roster-character small, .main-menu-mode small) { font-size: 12px; }
|
||||
.race-option > b { font-size: 10px; }
|
||||
.creator-classes { grid-template-rows: auto minmax(100px, 1fr) auto; overflow-y: auto; }
|
||||
.class-summary { max-height: 155px; overflow-y: auto; }
|
||||
.class-option small { font-size: 11px; }
|
||||
.game-panel :is(p, small, label, dt, dd, em), .game-panel :is(button, select, input) { font-size: 12px; }
|
||||
.game-panel :is(button, select, input), .pause-menu__actions .button { min-height: 44px; }
|
||||
.game-panel .icon-button { min-width: 44px; }
|
||||
.game-panel__footer { font-size: 11px; }
|
||||
.game-panel :is(.party-roster strong, .spellbook-ability strong, .inventory-member strong) { font-size: 13px; }
|
||||
.game-panel :is(.equipment-slot, .inventory-attributes-tabs button, .bindings-slot) { min-height: 44px; }
|
||||
.party-frames { width: 230px; }
|
||||
.party-frames > header { font-size: 11px; min-height: 18px; }
|
||||
.party-frame { min-height: 48px; }
|
||||
.party-frame__identity strong { font-size: 13px; }
|
||||
.party-frame__identity small { font-size: 10px; }
|
||||
.party-frame__health { height: 14px; }
|
||||
.party-frame__health b { font-size: 10px; }
|
||||
.party-command-bar strong { font-size: 11px; }
|
||||
.party-command-bar kbd { display: none; }
|
||||
.dungeon-complete-banner { width: min(610px, calc(100% - 24px)); }
|
||||
.dungeon-complete-banner strong { font-size: 14px; }
|
||||
.dungeon-complete-banner small { font-size: 11px; }
|
||||
.dungeon-complete-banner .button, .defeated-prompt__panel .button { min-height: 44px; font-size: 12px; }
|
||||
.defeated-prompt__panel > small { font-size: 11px; }
|
||||
.dungeon-results :is(button, select) { min-height: 44px; font-size: 13px; }
|
||||
.hud-minimap__caption span, .target-frame :is(small, header span) { font-size: 11px; }
|
||||
.hud-minimap__caption strong { font-size: 13px; }
|
||||
.target-frame header strong { font-size: 13px; }
|
||||
.class-hall-screen { display: grid; grid-template-columns: 230px minmax(0, 1fr); grid-template-rows: 54px minmax(0, 1fr) 24px; gap: 10px; }
|
||||
.class-hall-header { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--front-line); }
|
||||
.class-hall-header > div:not(.front-brand) { flex: 1; }
|
||||
.class-hall-header .front-brand { flex-direction: row; }
|
||||
.class-hall-header > button { padding: 8px 14px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--ink); background: #172b20; }
|
||||
.class-hall-character { grid-column: 1; grid-row: 2; display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; overflow: hidden; }
|
||||
.class-hall-character > .character-preview { height: 100%; min-height: 0; }
|
||||
.class-hall-character .character-preview figcaption { display: none; }
|
||||
.class-hall-character > div { padding: 10px; }
|
||||
.class-hall-character h1 { margin: 4px 0; font-size: 25px; }
|
||||
.class-hall-character small { display: block; }
|
||||
.class-hall-content { grid-column: 2; grid-row: 2; min-height: 0; overflow: auto; }
|
||||
.class-hall-screen > .controller-legend { grid-column: 1 / 3; grid-row: 3; }
|
||||
.content-manager-screen { display: grid; grid-template-rows: 60px minmax(0, 1fr) 24px; gap: 12px; }
|
||||
.content-manager-header { display: flex; align-items: center; gap: 16px; }
|
||||
.content-manager-header .front-brand { flex-direction: row; }
|
||||
.content-manager-header > div:not(.front-brand) { flex: 1; }
|
||||
.content-manager-main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: 0; gap: 12px; }
|
||||
.content-manager-summary, .content-manager-packs { padding: 12px; min-height: 0; overflow: auto; }
|
||||
.content-manager-summary h1 { font-size: 26px; line-height: 1.15; }
|
||||
.content-manager-summary__actions { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
Reference in new issue
Block a user