forked from phenom/healer-man
Release Healer Man 0.1.37
This commit is contained in:
1 parent
03a4e3f51c
commit
361734bc41
41 files changed
+1714
-110
No files matched your search
@@ -30,6 +30,10 @@ import { ExperienceBar } from "./ExperienceBar";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { ShelvedCompanionPanel } from "@shelvedCompanionPanel";
|
||||
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -456,14 +460,21 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
<div className="companion-tactical__overview">
|
||||
<section className="companion-map-card">
|
||||
<DungeonMapGraphic position={snapshot.game.playerPosition} yaw={snapshot.game.cameraYaw} compact />
|
||||
<div>
|
||||
<span>{snapshot.game.overlay ? "Upper display" : "Current area"}</span>
|
||||
<strong>{snapshot.game.overlay ? overlayName(snapshot.game.overlay, romSkills) : snapshot.game.areaName}</strong>
|
||||
<small>{Math.round(snapshot.game.distanceTravelled)} m explored / {heading} degrees</small>
|
||||
</div>
|
||||
</section>
|
||||
{snapshot.game.dpsMeter.display === "companion" && <EditableHudElement id="dps" hostSelector=".companion-panel"
|
||||
elementLayout={snapshot.game.dpsMeter.layout} editing={snapshot.game.dpsMeter.editing}
|
||||
onChange={(update) => send({ name: "editDpsMeter", update })}>
|
||||
<DpsMeterWidget compact snapshot={snapshot.game.dpsMeter.values} width={snapshot.game.dpsMeter.layout.width} />
|
||||
</EditableHudElement>}
|
||||
</div>
|
||||
<div className="companion-tactical__actions" style={{ gridColumn: "1 / 3", minWidth: 0 }}>
|
||||
<ActionBar
|
||||
items={actionItems}
|
||||
@@ -501,5 +512,7 @@ export function LocalCompanionPanel() {
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
|
||||
export function DpsMeter() {
|
||||
useDpsMeterStore();
|
||||
const ui = useUiLayoutStore();
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { DpsMeterSnapshot } from "../game/dpsMeter";
|
||||
import "./dpsMeter.css";
|
||||
|
||||
export function DpsMeterWidget({ snapshot, width, compact = false }: {
|
||||
snapshot: DpsMeterSnapshot; width?: number | null; compact?: boolean;
|
||||
}) {
|
||||
const number = (value: number) => Math.round(value).toLocaleString();
|
||||
return <section className={`dps-meter ${compact ? "dps-meter--compact" : ""}`} aria-label="DPS meter"
|
||||
style={{ "--dps-meter-width": `${width ?? 230}px` } as CSSProperties}>
|
||||
<header><strong>DPS</strong><small>{snapshot.rows.length === 0 ? "Waiting for combat" : snapshot.active ? "Current fight" : "Last fight"}</small>
|
||||
<span>{Math.floor(snapshot.durationSeconds / 60)}:{String(Math.floor(snapshot.durationSeconds % 60)).padStart(2, "0")}</span></header>
|
||||
{snapshot.rows.length > 0 ? <ol>{snapshot.rows.map((row) => <li key={row.id}
|
||||
title={`${row.name}: ${number(row.dps)} DPS · ${number(row.total)} damage`}
|
||||
style={{ "--dps-color": row.color } as CSSProperties}>
|
||||
<i style={{ width: `${row.percent}%` }} aria-hidden="true" /><span>{row.name}</span><b>{number(row.dps)}</b>
|
||||
</li>)}</ol> : <p>Damage dealt per second</p>}
|
||||
{snapshot.rows.length > 0 && <footer><span>Party · {number(snapshot.totalDamage)} damage</span><b>{number(snapshot.totalDps)} DPS</b></footer>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./uiLayout.css";
|
||||
|
||||
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
|
||||
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
|
||||
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
|
||||
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
|
||||
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
|
||||
@@ -14,14 +15,17 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
|
||||
};
|
||||
|
||||
/** Defaults remain in their original CSS flow until the player moves the widget. */
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
|
||||
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, elementLayout,
|
||||
editing: editingOverride, hostSelector = ".game-shell", onChange }: {
|
||||
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
|
||||
elementLayout?: HudElementLayout; editing?: boolean; hostSelector?: string;
|
||||
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const editing = overlay === "ui-layout";
|
||||
const editing = editingOverride ?? overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = layout.elements[id];
|
||||
const element = elementLayout ?? layout.elements[id];
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
@@ -34,14 +38,14 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
const node = ref.current;
|
||||
const measure = () => {
|
||||
const child = node.firstElementChild?.getBoundingClientRect();
|
||||
const host = node.closest(".game-shell")?.getBoundingClientRect();
|
||||
const host = node.closest(hostSelector)?.getBoundingClientRect();
|
||||
if (child && host && child.width > 0 && child.height > 0) {
|
||||
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
|
||||
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
|
||||
@@ -52,21 +56,23 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
|
||||
}, [positioned, children]);
|
||||
}, [positioned, children, hostSelector]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
const host = node?.closest(".game-shell");
|
||||
const host = node?.closest(hostSelector);
|
||||
if (!positioned || !node || !host) return;
|
||||
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host]);
|
||||
}, [positioned, host, hostSelector]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
|
||||
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
|
||||
} }));
|
||||
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 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;
|
||||
@@ -85,7 +91,7 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return;
|
||||
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
|
||||
const host = ref.current!.closest(hostSelector)!.getBoundingClientRect();
|
||||
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
|
||||
}}
|
||||
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
|
||||
|
||||
+23
-8
@@ -28,12 +28,15 @@ import { Minimap } from "./Minimap";
|
||||
import { PlayerStatusPanel } from "./PlayerStatusPanel";
|
||||
import { EncounterTimeline } from "./EncounterTimeline";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import { ShelvedHud } from "@shelvedHud";
|
||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||
import { BossLootWindow } from "./BossLootWindow";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeter } from "./DpsMeter";
|
||||
import { DesktopActionBars } from "./DesktopActionBars";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { SummonFrames } from "./SummonFrames";
|
||||
@@ -82,12 +85,16 @@ export function DefeatedPrompt({
|
||||
resurrectionCharges,
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
checkpoints = [],
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: () => void;
|
||||
onReturn: (checkpointId?: string) => void;
|
||||
sharedOnline?: boolean;
|
||||
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||
}) {
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||
return (
|
||||
<section
|
||||
@@ -103,11 +110,16 @@ export function DefeatedPrompt({
|
||||
Stay down and wait for a party member to resurrect you, or choose to return now.
|
||||
</p>
|
||||
{canReturn ? (
|
||||
<button type="button" className="button button--primary" onClick={onReturn}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: "Respawn at entrance"}
|
||||
</button>
|
||||
<>
|
||||
{gameMode === "dungeon" && checkpoints.length > 0 ? (
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint} />
|
||||
) : null}
|
||||
<button type="button" className="button button--primary" onClick={() => onReturn(gameMode === "dungeon" ? checkpointId : undefined)}>
|
||||
{gameMode === "manastorm"
|
||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||
: checkpointId === "entrance" ? "Respawn at entrance" : "Respawn at checkpoint"}
|
||||
</button>
|
||||
</>
|
||||
) : sharedOnline ? (
|
||||
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||
) : (
|
||||
@@ -159,6 +171,7 @@ export function Hud() {
|
||||
const assetStatus = useGameStore((state) => state.assetStatus);
|
||||
const assetMessage = useGameStore((state) => state.assetMessage);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const resurrectionCheckpoints = useResurrectionCheckpoints();
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
const resurrectionCharges = useManastormStore((state) => state.resurrectionCharges);
|
||||
@@ -349,12 +362,12 @@ export function Hud() {
|
||||
mapId: dungeon.mapId,
|
||||
areaName,
|
||||
});
|
||||
const returnFromDefeat = () => {
|
||||
const returnFromDefeat = (checkpointId?: string) => {
|
||||
if (gameMode === "manastorm") {
|
||||
resurrectManastormSession({ kind: "player" });
|
||||
return;
|
||||
}
|
||||
respawnDefeatedPlayer();
|
||||
respawnDefeatedPlayer(checkpointId ?? "entrance");
|
||||
};
|
||||
const finishDungeon = () => {
|
||||
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
|
||||
@@ -364,6 +377,7 @@ export function Hud() {
|
||||
return (
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
|
||||
@@ -501,6 +515,7 @@ export function Hud() {
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
checkpoints={resurrectionCheckpoints}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+29
-2
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
@@ -15,8 +15,11 @@ import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
import { onlinePlayerActorId, onlineSessionOwnsWorldSimulation } from "../game/onlineSessionRuntime";
|
||||
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -230,6 +233,11 @@ export function PartyFrames() {
|
||||
.map((aura) => aura.definition.name)
|
||||
.join(", "));
|
||||
const members = usePartyStore((state) => state.members);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossCombat = useCombatStore((state) => partyResurrectionBlockedByBoss(state.mobs));
|
||||
const checkpoints = useResurrectionCheckpoints();
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||
@@ -321,6 +329,25 @@ export function PartyFrames() {
|
||||
onSelect={() => selectMember(member.id)}
|
||||
/>
|
||||
))}
|
||||
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
|
||||
<div className="party-resurrection">
|
||||
<ResurrectionDestination checkpoints={checkpoints} value={checkpointId} onChange={setRequestedCheckpoint}
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()} />
|
||||
<button
|
||||
type="button"
|
||||
className="party-rez-button"
|
||||
disabled={bossCombat || !onlineSessionOwnsWorldSimulation()}
|
||||
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)}
|
||||
>
|
||||
<strong>Rez dead members</strong>
|
||||
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||
? "Simulation host only" : "Then return to you"}</small>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activeDungeonCompletedObjectiveIds, activeDungeonEncounterRevision, subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { dungeonResurrectionCheckpoints, type ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export function useResurrectionCheckpoints() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const mobs = useCombatStore((state) => state.mobs);
|
||||
const revision = useSyncExternalStore(subscribeDungeonEncounterSnapshots,
|
||||
activeDungeonEncounterRevision, activeDungeonEncounterRevision);
|
||||
return useMemo(() => dungeonResurrectionCheckpoints(requireDungeonDefinition(dungeonId), mobs,
|
||||
activeDungeonCompletedObjectiveIds(dungeonId)), [dungeonId, mobs, revision]);
|
||||
}
|
||||
|
||||
export function ResurrectionDestination({ checkpoints, value, onChange, disabled = false }: {
|
||||
checkpoints: readonly ResurrectionCheckpoint[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="resurrection-destination">
|
||||
<span>Resurrect at</span>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled}>
|
||||
{checkpoints.map((checkpoint) => <option key={checkpoint.id} value={checkpoint.id}>{checkpoint.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,23 @@ export function UiLayoutEditor() {
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
</details>
|
||||
<details open 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 } },
|
||||
}))} />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 } },
|
||||
}))}>
|
||||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||
</select></label>
|
||||
<label>Width<input type="range" aria-label="DPS meter width" min={160} max={600} step={10} value={draft.elements.dps.width ?? 230}
|
||||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
dps: { ...layout.elements.dps, width: Number(event.target.value) },
|
||||
} }))} /><output>{draft.elements.dps.width ?? 230}px</output></label>
|
||||
<p>Drag the DPS label on its screen to move it. Damage includes pets and summons. The last fight stays visible until the next pull.</p>
|
||||
</details>
|
||||
{ui.device === "pc" && <details open><summary>PC action bars</summary>
|
||||
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
|
||||
const combat = useCombatStore.getState();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
|
||||
import { ResurrectionDestination } from "./ResurrectionDestination";
|
||||
|
||||
describe("defeated prompt", () => {
|
||||
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
|
||||
@@ -25,6 +26,29 @@ describe("defeated prompt", () => {
|
||||
expect(markup).toContain("No shared resurrection charges remain");
|
||||
expect(markup).not.toContain("Resurrect now");
|
||||
});
|
||||
|
||||
it("offers labeled entrance and killed-boss choices on the player defeat prompt", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon", resurrectionCharges: 0, onReturn: () => undefined,
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
],
|
||||
}));
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("value=\"boss:bazil\"");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
});
|
||||
|
||||
it("retains a selected boss destination in the shared chooser", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ResurrectionDestination, {
|
||||
checkpoints: [
|
||||
{ id: "entrance", name: "Dungeon entrance", spawn: { footPosition: [0, 0, 0], yaw: 0 } },
|
||||
{ id: "boss:bazil", name: "Bazil Thredd", spawn: { footPosition: [20, 0, 0], yaw: 0 } },
|
||||
], value: "boss:bazil", onChange: () => undefined,
|
||||
}));
|
||||
expect(markup).toContain("value=\"boss:bazil\" selected=\"\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dungeon completion prompt", () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.dps-meter {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
bottom: max(32px, env(safe-area-inset-bottom));
|
||||
left: max(14px, env(safe-area-inset-left));
|
||||
width: min(var(--dps-meter-width, 230px), calc(100% - 28px));
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--edge, #536344);
|
||||
border-radius: 6px;
|
||||
background: rgba(5, 17, 12, .9);
|
||||
color: var(--ink, #ece5d0);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dps-meter > header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.dps-meter > header strong { color: var(--gold, #d5c28b); font: bold 13px Georgia, serif; }
|
||||
.dps-meter > header small { flex: 1; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > header span { font-size: 10px; }
|
||||
.dps-meter > p { margin: 4px 0; color: var(--muted, #a4b69a); font-size: 10px; }
|
||||
.dps-meter > ol { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.dps-meter li { position: relative; display: flex; align-items: center; gap: 8px; min-height: 20px; padding: 2px 5px; overflow: hidden; border-radius: 3px; background: #ffffff06; }
|
||||
.dps-meter li > i { position: absolute; inset: 0 auto 0 0; background: var(--dps-color); opacity: .3; }
|
||||
.dps-meter li > span { position: relative; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dps-meter li > b { position: relative; font-size: 10px; }
|
||||
.dps-meter > footer { display: flex; justify-content: space-between; gap: 5px; margin-top: 6px; color: var(--muted, #a4b69a); font-size: 9px; }
|
||||
.dps-meter > footer b { color: var(--ink, #ece5d0); white-space: nowrap; }
|
||||
.editable-hud--editing:has(> .dps-meter) { padding-top: 24px; }
|
||||
.dps-meter--compact { position: relative; inset: auto; width: min(var(--dps-meter-width, 230px), 100%); max-height: 150px; padding: 5px; font-size: 8px; }
|
||||
.dps-meter--compact > header { gap: 5px; margin-bottom: 3px; }
|
||||
.dps-meter--compact > header strong { font-size: 10px; }
|
||||
.dps-meter--compact > header :is(small, span), .dps-meter--compact > p { font-size: 7px; }
|
||||
.dps-meter--compact > ol { gap: 2px; }
|
||||
.dps-meter--compact li { min-height: 13px; padding: 1px 4px; }
|
||||
.dps-meter--compact li > b { font-size: 7px; }
|
||||
.dps-meter--compact > footer { margin-top: 3px; font-size: 6px; }
|
||||
.companion-tactical__overview { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-width: 0; min-height: 0; }
|
||||
.companion-tactical__overview > .companion-map-card { grid-template-columns: minmax(0, 1fr) 110px; min-height: 0; }
|
||||
.ui-layout-editor__dps { padding: 5px 0; }
|
||||
.ui-layout-editor__dps input[type="range"] { flex: 1; min-width: 0; }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "../game/dungeonEncounterRuntime";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { PartyFrames } from "./PartyFrames";
|
||||
|
||||
// Exercise the client HUD against live stores rather than their initial SSR snapshots.
|
||||
vi.mock("../game/partyStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/partyStore")>();
|
||||
return { ...actual, usePartyStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.usePartyStore.getState>) => unknown) => select(actual.usePartyStore.getState()), actual.usePartyStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/combatStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/combatStore")>();
|
||||
return { ...actual, useCombatStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useCombatStore.getState>) => unknown) => select(actual.useCombatStore.getState()), actual.useCombatStore,
|
||||
) };
|
||||
});
|
||||
vi.mock("../game/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/store")>();
|
||||
return { ...actual, useGameStore: Object.assign(
|
||||
(select: (state: ReturnType<typeof actual.useGameStore.getState>) => unknown) => select(actual.useGameStore.getState()), actual.useGameStore,
|
||||
) };
|
||||
});
|
||||
|
||||
const dungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
const render = () => renderToStaticMarkup(createElement(PartyFrames));
|
||||
function killMember() {
|
||||
const member = usePartyStore.getState().members[0];
|
||||
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
|
||||
}
|
||||
|
||||
describe("party resurrection UI", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().activateDungeon("stormwind-stockade");
|
||||
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
|
||||
resetDungeonEncounters("dungeon", dungeon.id);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
usePartyStore.getState().initializeParty("rez-ui", 16, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
});
|
||||
|
||||
it("hides the control when all NPCs are alive, even if only the player is dead", () => {
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
|
||||
it("places the button and labeled destination selector below every party frame", () => {
|
||||
killMember();
|
||||
const markup = render();
|
||||
expect(markup).toContain("Rez dead members");
|
||||
expect(markup).toContain("Resurrect at");
|
||||
expect(markup).toContain("Dungeon entrance");
|
||||
expect(markup.indexOf("party-resurrection")).toBeGreaterThan(markup.lastIndexOf("party-frame__identity"));
|
||||
expect(markup).not.toMatch(/class="party-rez-button" disabled/);
|
||||
});
|
||||
|
||||
it("shows why resurrection is disabled during a living boss encounter", () => {
|
||||
killMember();
|
||||
useCombatStore.getState().registerMob("boss", { name: "Boss", boss: true, maxHealth: 100 });
|
||||
useCombatStore.getState().damageMob("boss", 1, 100);
|
||||
expect(render()).toMatch(/class="party-rez-button" disabled=""/);
|
||||
expect(render()).toContain("Unavailable during boss combat");
|
||||
});
|
||||
|
||||
it("offers killed boss destinations but not bosses that are still alive", () => {
|
||||
killMember();
|
||||
const boss = dungeon.bosses[0];
|
||||
expect(render()).not.toContain(`value="boss:${boss.id}"`);
|
||||
useCombatStore.getState().registerMob(boss.id, { name: boss.name, boss: true, maxHealth: 1 });
|
||||
useCombatStore.getState().damageMob(boss.id, 1, 100);
|
||||
expect(render()).toContain(`value="boss:${boss.id}"`);
|
||||
});
|
||||
|
||||
it("does not offer free resurrection in Manastorm", () => {
|
||||
killMember();
|
||||
useGameStore.setState({ gameMode: "manastorm" });
|
||||
expect(render()).not.toContain("Rez dead members");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user