Release Healer Man 0.1.43

This commit is contained in:
phenom committed 2026-09-14 13:20:20 -04:00
1 parent c7e8163790
commit ccc01396cd
86 files changed
+2951 -198

No files matched your search

+38 -12
View File
@@ -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>}