205 lines
11 KiB
TypeScript
205 lines
11 KiB
TypeScript
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
|
||
import { normalizeInputBinding, INPUT_ACTIONS, normalizeInputPreferences, type InputPreferences } from "../input/keybinds";
|
||
|
||
export type UiDevice = "mobile" | "pc";
|
||
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
|
||
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||
export const HUD_ELEMENTS = {
|
||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
|
||
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
|
||
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
|
||
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
|
||
} as const;
|
||
export type HudElementId = keyof typeof HUD_ELEMENTS;
|
||
export interface HudElementLayout {
|
||
visible: boolean;
|
||
position: NormalizedHudPosition | null;
|
||
width: number | null;
|
||
}
|
||
export interface DesktopActionSlot { abilityId: string | null; key: string | null }
|
||
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
|
||
export interface UiLayout {
|
||
elements: Record<HudElementId, HudElementLayout>;
|
||
dpsMeterDisplay: "auto" | "main" | "companion";
|
||
wowBars: boolean;
|
||
barCount: number;
|
||
bars: DesktopActionBar[];
|
||
}
|
||
export interface UiTemplate { name: string; layout: UiLayout }
|
||
export interface UiPreferences {
|
||
version: 1;
|
||
keybinds: InputPreferences;
|
||
current: Record<UiDevice, UiLayout>;
|
||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||
}
|
||
|
||
export function defaultUiLayout(): UiLayout {
|
||
return {
|
||
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
|
||
visible: true, position: null, width: null,
|
||
}])) as UiLayout["elements"],
|
||
dpsMeterDisplay: "auto",
|
||
wowBars: false,
|
||
barCount: 1,
|
||
bars: Array.from({ length: 5 }, (_, bar) => ({
|
||
columns: 12,
|
||
slots: Array.from({ length: 12 }, (_, slot) => ({
|
||
abilityId: null,
|
||
key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null,
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
function bounded(value: unknown, fallback: number, min: number, max: number): number {
|
||
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
||
}
|
||
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
|
||
export function normalizeActionKey(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const parts = value.split("+");
|
||
const code = parts.pop()!;
|
||
if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
|
||
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
|
||
}
|
||
export function actionKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
|
||
return normalizeActionKey([
|
||
...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []),
|
||
...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code,
|
||
].join("+"));
|
||
}
|
||
export function actionKeyLabel(key: string | null): string {
|
||
return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? "";
|
||
}
|
||
export function normalizeUiLayout(value: unknown): UiLayout {
|
||
const source = record(value);
|
||
const defaults = defaultUiLayout();
|
||
const elements = record(source.elements);
|
||
for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) {
|
||
const element = record(elements[id]);
|
||
defaults.elements[id] = {
|
||
visible: element.visible !== false,
|
||
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
|
||
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
|
||
};
|
||
}
|
||
const used = new Set<string>();
|
||
defaults.bars = defaults.bars.map((fallback, index) => {
|
||
const bar = record(Array.isArray(source.bars) ? source.bars[index] : null);
|
||
return {
|
||
columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12,
|
||
slots: fallback.slots.map((slot, slotIndex) => {
|
||
const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot;
|
||
const key = normalizeActionKey(raw.key);
|
||
const uniqueKey = key && !used.has(key) ? key : null;
|
||
if (uniqueKey) used.add(uniqueKey);
|
||
return {
|
||
key: uniqueKey,
|
||
abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null,
|
||
};
|
||
}),
|
||
};
|
||
});
|
||
return { ...defaults,
|
||
dpsMeterDisplay: source.dpsMeterDisplay === "main" || source.dpsMeterDisplay === "companion" ? source.dpsMeterDisplay : "auto",
|
||
wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
|
||
}
|
||
|
||
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||
}
|
||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||
const source = record(value);
|
||
const current = record(source.current);
|
||
const slots = record(source.slots);
|
||
return {
|
||
version: 1,
|
||
keybinds: normalizeInputPreferences(source.keybinds),
|
||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||
return typeof slot.name === "string" && slot.layout
|
||
? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) }
|
||
: null;
|
||
})])) as UiPreferences["slots"],
|
||
};
|
||
}
|
||
|
||
/** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */
|
||
export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout {
|
||
return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({
|
||
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
|
||
})) })) };
|
||
}
|
||
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
||
if (!layout.wowBars || !key) return null;
|
||
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
||
return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null;
|
||
}
|
||
export function exportUiPreferences(preferences: UiPreferences): string {
|
||
return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2);
|
||
}
|
||
export function importUiPreferences(text: string): UiPreferences {
|
||
if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB.");
|
||
let source: Record<string, unknown>;
|
||
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||
const validateLayout = (value: unknown) => {
|
||
const layout = record(value);
|
||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|
||
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
|
||
for (const id of Object.keys(HUD_ELEMENTS)) {
|
||
// Version-one exports made before the DPS meter inherit its defaults.
|
||
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||
const element = record(record(layout.elements)[id]);
|
||
const position = record(element.position);
|
||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
|
||
}
|
||
const keys = new Set<string>();
|
||
for (const raw of layout.bars) {
|
||
const bar = record(raw);
|
||
if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout.");
|
||
for (const rawSlot of bar.slots) {
|
||
const slot = record(rawSlot);
|
||
if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment.");
|
||
if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind.");
|
||
if (slot.key) keys.add(normalizeActionKey(slot.key)!);
|
||
}
|
||
}
|
||
};
|
||
if (source.keybinds !== undefined) {
|
||
if (!source.keybinds || typeof source.keybinds !== "object" || Array.isArray(source.keybinds)) throw new Error("Invalid keybind configuration.");
|
||
for (const device of ["pc", "controller"] as const) {
|
||
const raw = record(source.keybinds)[device];
|
||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid keybind configuration.");
|
||
const bindings = record(record(source.keybinds)[device]);
|
||
for (const entry of INPUT_ACTIONS) {
|
||
const key = bindings[entry.id];
|
||
if (key !== undefined && key !== null && !normalizeInputBinding(device, key)) throw new Error("Invalid keybind configuration.");
|
||
}
|
||
}
|
||
}
|
||
for (const device of UI_DEVICES) {
|
||
validateLayout(record(source.current)[device]);
|
||
const slots = record(source.slots)[device];
|
||
if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots.");
|
||
for (const slot of slots) {
|
||
if (slot === null) continue;
|
||
const template = record(slot);
|
||
if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters.");
|
||
validateLayout(template.layout);
|
||
}
|
||
}
|
||
return normalizeUiPreferences(source);
|
||
}
|