forked from phenom/healer-man
Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -5,14 +5,16 @@ import {
|
||||
ACTION_CONTROL_DEFINITIONS,
|
||||
ACTION_LAYERS,
|
||||
ACTION_LAYER_LABELS,
|
||||
actionBindingChordLabel,
|
||||
actionBindingId,
|
||||
type ActionBindingControl,
|
||||
type ActionBindingLayer,
|
||||
} from "../game/actionBindings";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
|
||||
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
@@ -82,6 +84,8 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
|
||||
};
|
||||
|
||||
function AbilityBindingsDialog() {
|
||||
const keyLabel = useInputBindingLabels();
|
||||
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
@@ -130,14 +134,14 @@ function AbilityBindingsDialog() {
|
||||
const control = editingControl;
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
useCombatStore.getState().setActionBinding(layer, control, abilityId);
|
||||
setNotice(`${actionBindingChordLabel(layer, control)}: ${ability?.name ?? "Empty"}.`);
|
||||
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const clearSelected = (control: ActionBindingControl | null = editingControl) => {
|
||||
if (!control) return;
|
||||
useCombatStore.getState().setActionBinding(layer, control, null);
|
||||
setNotice(`${actionBindingChordLabel(layer, control)} cleared.`);
|
||||
setNotice(`${bindingChord(layer, control)} cleared.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
@@ -224,19 +228,30 @@ function AbilityBindingsDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeControllerToken(({ token, repeat, pressed }) => {
|
||||
if (pressed === false || repeat) return;
|
||||
const run = (id: string | undefined) => {
|
||||
if (restoreRequest) return;
|
||||
if (token === "Button4") chooseLayer("secondary");
|
||||
else if (token === "Button6") chooseLayer("tertiary");
|
||||
else if (token === "Button2") {
|
||||
if (id === "editor-layer-2") chooseLayer("secondary");
|
||||
else if (id === "editor-layer-3") chooseLayer("tertiary");
|
||||
else if (id === "editor-clear") {
|
||||
if (editingControl) clearSelected(editingControl);
|
||||
else {
|
||||
const prefix = "binding-slot-";
|
||||
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
|
||||
}
|
||||
} else if (token === "Button3") requestRestore("layer", controller.selectedId);
|
||||
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
|
||||
};
|
||||
const unsubscribe = subscribeControllerToken(({ token, repeat, pressed }) => {
|
||||
if (pressed === false || repeat || isCapturingInput()) return;
|
||||
run(INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && controllerBindingMatches(getInputPreferences().controller[entry.id], token, getControllerHeldTokens()))?.id);
|
||||
});
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.repeat || event.defaultPrevented || isCapturingInput() || (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']"))) return;
|
||||
const key = inputKeyForEvent(event);
|
||||
const id = key && INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && getInputPreferences().pc[entry.id] === key)?.id;
|
||||
if (id) { event.preventDefault(); run(id); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
|
||||
}, [controller.selectedId, editingControl, layer, restoreRequest]);
|
||||
|
||||
const bindingSlot = (control: ActionBindingControl) => {
|
||||
@@ -252,11 +267,11 @@ function AbilityBindingsDialog() {
|
||||
select={controller.select}
|
||||
type="button"
|
||||
className={`ability-binding-slot ability-binding-slot--${control} ${editingControl === control ? "is-editing" : ""} ${locked ? "is-locked" : ""}`}
|
||||
aria-label={`${actionBindingChordLabel(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
|
||||
aria-label={`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
|
||||
aria-pressed={editingControl === control}
|
||||
onClick={() => beginEditing(control)}
|
||||
>
|
||||
<kbd>{definition.gamepadLabel}</kbd>
|
||||
<kbd>{keyLabel("controller", `ability:${layer}:${control}`)}</kbd>
|
||||
{ability
|
||||
? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
||||
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
@@ -340,7 +355,7 @@ function AbilityBindingsDialog() {
|
||||
</>
|
||||
) : editingControl ? (
|
||||
<>
|
||||
<p className="eyebrow">Assign {actionBindingChordLabel(layer, editingControl)}</p>
|
||||
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
|
||||
<h3>Choose an ability</h3>
|
||||
<div className="ability-bindings__choices">
|
||||
{pickerAbilities.map((ability) => (
|
||||
@@ -368,8 +383,8 @@ function AbilityBindingsDialog() {
|
||||
|
||||
<footer className="game-panel__footer">
|
||||
{restoreRequest
|
||||
? <><span><kbd>×</kbd> Choose</span><span><kbd>○</kbd> Cancel</span></>
|
||||
: <><span><kbd>×</kbd> Assign</span><span><kbd>□</kbd> Clear</span><span><kbd>○</kbd> Back</span><span><kbd>△</kbd> Restore layer</span><span><kbd>L1 / L2</kbd> Preview layer</span></>}
|
||||
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
|
||||
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore layer</span></>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import {
|
||||
ACTION_CONTROL_DEFINITIONS,
|
||||
ACTION_LAYER_LABELS,
|
||||
actionBindingChordLabel,
|
||||
type ActionBindingControl,
|
||||
type ActionBindingLayer,
|
||||
} from "../game/actionBindings";
|
||||
@@ -70,15 +69,15 @@ function ActionSlot({
|
||||
onInspect: (control: ActionBindingControl) => void;
|
||||
onStopInspecting: (control: ActionBindingControl) => void;
|
||||
}) {
|
||||
const definition = ACTION_CONTROL_DEFINITIONS[control];
|
||||
const cooldown = cooldownPercent(item?.cooldownRemaining, item?.cooldownDuration);
|
||||
const unavailable = !item || Boolean(item.disabled);
|
||||
const style = {
|
||||
"--ability-color": item?.color ?? "#789b68",
|
||||
"--cooldown-fill": `${cooldown}%`,
|
||||
} as CSSProperties;
|
||||
const bindingLabel = inputMode === "gamepad" ? definition.gamepadLabel : definition.keyboardLabel;
|
||||
const chord = actionBindingChordLabel(layer, control, inputMode);
|
||||
const label = useInputBindingLabels();
|
||||
const chord = label(inputMode === "gamepad" ? "controller" : "pc", `ability:${layer}:${control}`);
|
||||
const bindingLabel = chord.replace(/ \/ [^+]+/g, "").replace(/Left stick/g, "LS").replace(/Right stick/g, "RS");
|
||||
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
|
||||
? ", cooling down"
|
||||
: "";
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { arenaCharacterAbilities, arenaAbilityIsLearned, createArenaCharacterKit } from "../game/arenaCharacter";
|
||||
import { ACTION_CONTROLS, actionBindingId, type ActionBindingLayer, type ActionBindingControl } from "../game/actionBindings";
|
||||
import type { ArenaActorSnapshot } from "../game/arenaTypes";
|
||||
import { actionKeyLabel } from "../game/uiLayout";
|
||||
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";
|
||||
|
||||
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
|
||||
|
||||
export function ArenaActionBar({ character, actor, layer, inputMode, blocked, onCast, feedback }: {
|
||||
character: CharacterProfile;
|
||||
actor: ArenaActorSnapshot | null;
|
||||
layer: ActionBindingLayer;
|
||||
inputMode: "keyboard" | "gamepad";
|
||||
blocked: boolean;
|
||||
onCast: (abilityId: string) => void;
|
||||
feedback: string;
|
||||
}) {
|
||||
const ui = useUiLayoutStore();
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const level = actor?.level ?? character.level;
|
||||
const kit = useMemo(() => createArenaCharacterKit(character, level), [character, level]);
|
||||
const abilities = useMemo(() => arenaCharacterAbilities(character, level), [character, level]);
|
||||
const layout = ui.preferences.current[currentUiDevice()];
|
||||
const bindings = actor?.actionBindings ?? kit.actionBindings;
|
||||
const desktopBars = layout.wowBars ? layout.bars.slice(0, layout.barCount).map((bar) => ({ ...bar, slots: bar.slots.map((slot) => ({ ...slot, label: actionKeyLabel(slot.key) })) }))
|
||||
: [{ columns: 8, slots: PC_ACTION_CONTROLS.map((control) => ({ abilityId: bindings[actionBindingId(layer, control)], label: bindingLabel("pc", `ability:${layer}:${control}`) })) }];
|
||||
const items = Object.fromEntries(abilities.map((ability): [string, ActionBarItem] => {
|
||||
const learned = actor?.abilityIds ? actor.abilityIds.includes(ability.id) : arenaAbilityIsLearned(ability, character, level);
|
||||
const remaining = Math.max(actor?.cooldowns[ability.id] ?? 0, actor?.gcdRemainingMs ?? 0) / 1000;
|
||||
return [ability.id, {
|
||||
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
|
||||
description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
|
||||
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
|
||||
cooldownRemaining: remaining,
|
||||
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
|
||||
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
|
||||
}];
|
||||
}));
|
||||
const casting = actor?.effects.find((effect) => effect.id.startsWith("casting:"));
|
||||
const castName = casting ? items[casting.id.slice(8)]?.name : null;
|
||||
const castBinding = (control: ActionBindingControl) => {
|
||||
const id = bindings[actionBindingId(layer, control)];
|
||||
if (id) onCast(id);
|
||||
};
|
||||
return <div className="arena-player-hud">
|
||||
<div className="arena-player-status"><strong>{character.name}</strong><span>Level {level} · {kit.classId.replace("rom-", "")} · {kit.resource.name} {actor?.resource ?? 0}/{actor?.maxResource ?? kit.resource.maximum}</span></div>
|
||||
<div className="arena-extra-resources" aria-label="Character resources">
|
||||
{Object.entries(actor?.combat?.resourcePools ?? {}).filter(([type]) => type !== actor?.resourceType).map(([type, amount]) => <span key={type}>{type}: {Math.round(amount ?? 0)}/{actor?.combat?.maxResourcePools[type as keyof typeof actor.combat.maxResourcePools] ?? 0}</span>)}
|
||||
{(actor?.combat?.comboPoints ?? 0) > 0 && <span>Combo points: {actor!.combat!.comboPoints}/5</span>}
|
||||
{actor?.combat?.runes.map((rune) => <span key={rune.slot}>{rune.type}: {Math.max(0, (rune.readyAt - (useArenaStore.getState().snapshot?.serverTime ?? 0)) / 1000).toFixed(1)}s</span>)}
|
||||
{(actor?.combat?.shield ?? 0) > 0 && <span>Absorb: {Math.round(actor!.combat!.shield)}</span>}
|
||||
</div>
|
||||
<div className="arena-aura-strip" aria-label="Your buffs and debuffs">{actor?.effects.filter((effect) => !effect.id.startsWith("casting:")).slice(0, 12).map((effect, index) => <span className={effect.disposition === "debuff" ? "is-debuff" : ""} key={`${effect.id}:${index}`} title={effect.id}>{effect.icon && <img src={effect.icon} alt="" />}{effect.id.replace(/ \(DBC.*$/, "")} {effect.stacks && effect.stacks > 1 ? `×${effect.stacks}` : ""} {effect.remainingMs > 0 ? `${Math.ceil(effect.remainingMs / 1000)}s` : ""}</span>)}</div>
|
||||
{(actor?.combat?.summons.length ?? 0) > 0 && <div className="arena-pet-orders" aria-label="Pet commands">{(["attack", "defend", "stop", "recall"] as const).map((command) => <button key={command} type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command })}>{command}</button>)}</div>}
|
||||
{castName && <div className="arena-cast-status" role="status">{actor?.combat?.activeCast?.mode === "channel" ? "Channeling" : "Casting"} {castName} · {(casting!.remainingMs / 1000).toFixed(1)}s <button type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command: "cancel-cast" })}>Cancel</button></div>}
|
||||
{feedback && <div className="arena-action-feedback" role="status">{feedback}</div>}
|
||||
{currentUiDevice() === "pc" && inputMode !== "gamepad" ? (
|
||||
<nav className="arena-personal-bars" aria-label="Your arena action bars">
|
||||
{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}
|
||||
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
|
||||
onClick={() => item && 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>;
|
||||
})}
|
||||
</section>)}
|
||||
</nav>
|
||||
) : <ActionBar variant="compact" inputMode={inputMode} activeLayer={layer}
|
||||
items={Object.fromEntries(ACTION_CONTROLS.map((control) => [control, items[bindings[actionBindingId(layer, control)] ?? ""] ?? null])) as ActionBarItems}
|
||||
onActivate={castBinding} />}
|
||||
<small className="arena-control-hints">{inputMode === "gamepad"
|
||||
? `Left stick move · Right stick look · ${bindingLabel("controller", "target-next")} enemy · ${bindingLabel("controller", "party-next")} ally`
|
||||
: `${["move-forward", "move-left", "move-backward", "move-right"].map((id) => bindingLabel("pc", id)).join("/")} move · Hold right mouse to look · ${bindingLabel("pc", "target-next")} enemy · Click a team frame to target`}</small>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useArenaStore } from "../game/arenaStore";
|
||||
import { abilitiesForCharacter, abilityAtLevel } from "../game/abilityCatalog";
|
||||
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot, ArenaStageManifest } from "../game/arenaTypes";
|
||||
import { SpellbookPanel } from "./SpellbookPanel";
|
||||
import { EquipmentPanel } from "./EquipmentPanel";
|
||||
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
|
||||
import { KeybindsPanel } from "./KeybindsPanel";
|
||||
import { OptionsPanel } from "./OptionsPanel";
|
||||
import { UiLayoutEditor } from "./UiLayoutEditor";
|
||||
|
||||
const Talents = lazy(() => import("./TalentGameplayPanel"));
|
||||
|
||||
export function ArenaPanels({ character, actor, snapshot, stage, cast }: {
|
||||
character: CharacterProfile; actor: ArenaActorSnapshot | null; snapshot: ArenaAuthoritativeSnapshot;
|
||||
stage: ArenaStageManifest; cast: (id: string) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const presentation = useShellStore((state) => state.combatPresentationSettings);
|
||||
const close = () => useGameStore.getState().closeOverlay();
|
||||
const color = classById(character.classId).color;
|
||||
const abilities = useMemo(() => abilitiesForCharacter(character.classId, character.secondaryClassId).map((definition) => {
|
||||
const rank = definition.talentEntryId === undefined ? undefined : actor?.combat?.talentRanks[`coa-entry-${definition.talentEntryId}`];
|
||||
const ability = abilityAtLevel(definition, actor?.level ?? character.level, rank);
|
||||
return { id: ability.id, name: ability.name, description: ability.description, level: ability.unlockLevel,
|
||||
color, sigil: ability.sigil, iconUrl: ability.icon, known: ability.passive || Boolean(actor?.abilityIds?.includes(ability.id)),
|
||||
passive: ability.passive, resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource,
|
||||
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max };
|
||||
}), [actor?.level, actor?.abilityIds, character, color]);
|
||||
return <>
|
||||
<SpellbookPanel abilities={abilities} playerLevel={actor?.level ?? character.level} open={overlay === "spellbook"} onClose={close}
|
||||
onActivate={(id) => { close(); cast(id); }} />
|
||||
<EquipmentPanel readOnly />
|
||||
{overlay === "talents" && <Suspense fallback={<div className="arena-result-panel">Loading talents…</div>}><Talents readOnly /></Suspense>}
|
||||
<AbilityBindingsPanel /><KeybindsPanel /><UiLayoutEditor />
|
||||
<OptionsPanel settings={settings} open={overlay === "options"} onClose={close}
|
||||
onChange={(patch) => useCombatStore.getState().updateSettings(patch)} onReset={() => useCombatStore.getState().resetSettings()}
|
||||
presentationSettings={presentation} onPresentationChange={(patch) => useShellStore.getState().updateCombatPresentationSettings(patch)}
|
||||
onPresentationReset={() => useShellStore.getState().resetCombatPresentationSettings()} />
|
||||
{(overlay === "map" || overlay === "party" || overlay === "boss-guide") && <section className="arena-result-panel arena-reference-panel" role="dialog" aria-label="Arena overview">
|
||||
<button type="button" onClick={close}>Close</button><h2>{stage.name}</h2>
|
||||
<svg viewBox={`${stage.bounds.min[0]} ${stage.bounds.min[2]} ${stage.bounds.max[0] - stage.bounds.min[0]} ${stage.bounds.max[2] - stage.bounds.min[2]}`} aria-label="Arena map">
|
||||
{stage.lineOfSight.map((object) => <rect key={object.id} x={object.position[0] - object.size[0] / 2} y={object.position[2] - object.size[2] / 2} width={object.size[0]} height={object.size[2]} fill="#717c76" />)}
|
||||
{snapshot.actors.filter((entry) => entry.alive).map((entry) => <circle key={entry.id} cx={entry.position[0]} cy={entry.position[2]} r={entry.id === actor?.id ? 2 : 1.4} fill={entry.team === actor?.team ? "#71c9ff" : "#ff736e"}><title>{entry.name}</title></circle>)}
|
||||
</svg>
|
||||
<p>Win by defeating the opposing team. Healing dampening: {Math.round(snapshot.dampening * 100)}%.</p>
|
||||
<p>Talents and equipment use the loadout you entered with. Change them between matches.</p>
|
||||
<div className="arena-pet-orders">{(["attack", "defend", "stop", "recall"] as const).map((command) => <button key={command} type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command })}>{command}</button>)}</div>
|
||||
</section>}
|
||||
</>;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function ContentManagerScreen({ onClose }: { readonly onClose: () => void
|
||||
<article key={pack.id} className="content-pack-row">
|
||||
<div>
|
||||
<strong>{pack.label}</strong>
|
||||
<small>{formatBytes(pack.bytes)} / {pack.fileCount} files</small>
|
||||
<small>{formatBytes(pack.downloadBytes ?? pack.bytes)} download / {pack.fileCount} files</small>
|
||||
</div>
|
||||
<span className={installed ? "is-installed" : ""}>
|
||||
{update ? "Update available" : bundled ? "Included — always available" : installed ? "Installed — offline ready" : "Not installed"}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
interactWithActiveDungeonObject,
|
||||
} from "../game/dungeonWorldInteraction";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
|
||||
export function DungeonInteractionPrompt() {
|
||||
const label = useInputBindingLabels();
|
||||
const playerPosition = useGameStore((state) => state.playerPosition);
|
||||
const inputMode = useGameStore((state) => state.inputMode);
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
@@ -28,7 +30,7 @@ export function DungeonInteractionPrompt() {
|
||||
onClick={() => interactWithActiveDungeonObject()}
|
||||
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
|
||||
>
|
||||
<span className="dungeon-interaction-prompt__key">{inputMode === "gamepad" ? "A" : "E"}</span>
|
||||
<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>
|
||||
|
||||
@@ -184,7 +184,7 @@ function itemType(item: InventoryItem): string {
|
||||
].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function EquipmentDialog() {
|
||||
function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const inventory = useCombatStore((state) => state.inventory);
|
||||
const equipment = useCombatStore((state) => state.equipment);
|
||||
@@ -255,6 +255,7 @@ function EquipmentDialog() {
|
||||
|
||||
const equip = (item: InventoryItem) => {
|
||||
setInspectedId(item.instanceId);
|
||||
if (readOnly) return;
|
||||
useCombatStore.getState().equipItem(item.instanceId, owner.id);
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
@@ -264,7 +265,7 @@ function EquipmentDialog() {
|
||||
})),
|
||||
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
|
||||
id: `equipment-slot-${slot}`,
|
||||
run: () => useCombatStore.getState().unequipItem(owner.id, slot),
|
||||
run: () => { if (!readOnly) useCombatStore.getState().unequipItem(owner.id, slot); },
|
||||
}] : []),
|
||||
...STAT_PAGES.map((page) => ({
|
||||
id: `equipment-stat-${page}`,
|
||||
@@ -393,7 +394,7 @@ function EquipmentDialog() {
|
||||
type="button"
|
||||
className={`equipment-slot equipment-quality--${item.quality}`}
|
||||
onFocus={() => setInspectedId(item.instanceId)}
|
||||
disabled={!earned}
|
||||
disabled={readOnly || !earned}
|
||||
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
|
||||
>
|
||||
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
|
||||
@@ -484,7 +485,7 @@ function EquipmentDialog() {
|
||||
<button
|
||||
type="button"
|
||||
className="button button--primary"
|
||||
disabled={!compatible}
|
||||
disabled={readOnly || !compatible}
|
||||
onClick={() => equip(inspected)}
|
||||
>{inspectedOwnerId === owner.id ? "Re-equip item" : `Equip on ${owner.name}`}</button>
|
||||
</>
|
||||
@@ -505,7 +506,7 @@ function EquipmentDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentPanel() {
|
||||
export function EquipmentPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
|
||||
const open = useGameStore((state) => state.overlay === "inventory");
|
||||
return open ? <EquipmentDialog /> : null;
|
||||
return open ? <EquipmentDialog readOnly={readOnly} /> : null;
|
||||
}
|
||||
+5
-15
@@ -1,3 +1,4 @@
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -142,6 +143,7 @@ export function DungeonCompletePrompt({
|
||||
}
|
||||
|
||||
export function Hud() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const ui = useUiLayoutStore();
|
||||
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
@@ -206,15 +208,6 @@ export function Hud() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGroundTarget) return;
|
||||
const cancel = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") useCombatStore.getState().cancelGroundTarget();
|
||||
};
|
||||
window.addEventListener("keydown", cancel);
|
||||
return () => window.removeEventListener("keydown", cancel);
|
||||
}, [pendingGroundTarget]);
|
||||
|
||||
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
|
||||
useCombatStore.getState().updateSettings({ threatMeterPosition });
|
||||
}, []);
|
||||
@@ -388,7 +381,7 @@ export function Hud() {
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
<div aria-hidden="true"><i /><b /></div>
|
||||
<strong>Place ground effect</strong>
|
||||
<small>Move with WASD / left stick. Confirm with Enter / A; Escape / B cancels.</small>
|
||||
<small>Confirm: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-confirm")} · Cancel: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-cancel")}</small>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -473,11 +466,8 @@ export function Hud() {
|
||||
|
||||
<EditableHudElement id="hints">{!activeCast && (
|
||||
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
|
||||
{inputMode === "gamepad"
|
||||
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
|
||||
: cameraLookActive
|
||||
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
|
||||
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
|
||||
{cameraLookActive ? "Release right mouse for cursor · " : "Hold right mouse to look · "}
|
||||
Forward: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "move-forward")} · Target: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "target-next")} · Pause: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "pause")}
|
||||
</div>
|
||||
)}</EditableHudElement>
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { defaultUiLayout } from "../game/uiLayout";
|
||||
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "../game/keybindPreferences";
|
||||
import {
|
||||
CONTROLLER_BINDING_CHOICES, INPUT_ACTIONS, INPUT_ACTION_BY_ID, conflictingInputActions,
|
||||
defaultInputDevice, defaultInputBindings, inputBindingLabel, inputKeyForEvent, normalizeInputBinding, replaceInputBinding, type InputDevice,
|
||||
} from "../input/keybinds";
|
||||
import { getControllerHeldTokens, interceptControllerInput, subscribeControllerMovement } from "../input/controller";
|
||||
import { setCapturingInput } from "../input/keybindRuntime";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import "./keybinds.css";
|
||||
|
||||
const PC_KEYS = [
|
||||
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
|
||||
...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
|
||||
"Space", "Tab", "Enter", "Escape", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||
"Backquote", "Minus", "Equal", "BracketLeft", "BracketRight", "Backslash", "Semicolon", "Quote", "Comma", "Period", "Slash",
|
||||
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
|
||||
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
|
||||
];
|
||||
function KeybindsDialog() {
|
||||
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
|
||||
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
|
||||
const [search, setSearch] = useState("");
|
||||
const [group, setGroup] = useState("All actions");
|
||||
const [conflictsOnly, setConflictsOnly] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [listening, setListening] = useState(false);
|
||||
const [pending, setPending] = useState<{ id: string; key: string; conflicts: string[] } | null>(null);
|
||||
const [resetPending, setResetPending] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [manualKey, setManualKey] = useState("KeyW");
|
||||
const [modifiers, setModifiers] = useState<string[]>([]);
|
||||
const [controllerModifier, setControllerModifier] = useState("");
|
||||
const dialog = useRef<HTMLElement>(null);
|
||||
const cloud = useCloudSaveStatus();
|
||||
const online = useShellStore((state) => Boolean(state.session?.accessToken));
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const assignments = useCombatStore((state) => state.actionBindings);
|
||||
const bindings = profileInputBindings(draft, device);
|
||||
const conflicts = INPUT_ACTIONS.filter((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).length > 0);
|
||||
const detail = (id: string): string => {
|
||||
const abilityId = id.startsWith("ability:") ? assignments[id.slice(8) as keyof typeof assignments]
|
||||
: id.startsWith("bar:") ? draft.current.pc.bars[Number(id.split(":")[1])]?.slots[Number(id.split(":")[2])]?.abilityId : null;
|
||||
if (id.startsWith("ability:") || id.startsWith("bar:")) return abilities.find((ability) => ability.id === abilityId)?.name ?? "Empty ability slot";
|
||||
if (id === "loot" && device === "controller") return "When interaction is available, it takes priority over the base Cross ability.";
|
||||
return "";
|
||||
};
|
||||
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 endCapture = () => { setListening(false); setCapturingInput(false); };
|
||||
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
|
||||
const apply = (id: string, key: string | null) => {
|
||||
setDraft((current) => withProfileInputBindings(current, device, replaceInputBinding(profileInputBindings(current, device), device, id, key)));
|
||||
setMessage(`${INPUT_ACTION_BY_ID[id].label}: ${inputBindingLabel(device, key)}. Save changes to keep it.`);
|
||||
cancelEdit();
|
||||
};
|
||||
const offer = (key: string) => {
|
||||
if (!editing) return;
|
||||
endCapture();
|
||||
const collisions = conflictingInputActions(bindings, device, editing, key);
|
||||
if (collisions.length) setPending({ id: editing, key, conflicts: collisions });
|
||||
else apply(editing, key);
|
||||
};
|
||||
const startCapture = () => { setCapturingInput(true); setListening(true); };
|
||||
const editBinding = (id: string) => {
|
||||
setEditing(id); setManualKey(device === "pc" ? "KeyW" : "Button0"); setModifiers([]); setControllerModifier("");
|
||||
};
|
||||
const save = () => {
|
||||
try { useUiLayoutStore.getState().persist(draft); setMessage("Keybinds saved to your profile."); }
|
||||
catch (error) { setMessage(error instanceof Error ? error.message : "Could not save keybinds."); }
|
||||
};
|
||||
const restoreMovement = () => {
|
||||
setDraft((current) => restoreMovementBindings(current, device));
|
||||
setMessage("Movement and jump restored. Conflicting bindings were cleared. Save changes to keep this repair.");
|
||||
};
|
||||
const restoreDefaults = () => {
|
||||
let defaults = defaultInputBindings(device);
|
||||
if (device === "pc") defaultUiLayout().bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { defaults[`bar:${b}:${s}`] = slot.key; }));
|
||||
setDraft((current) => withProfileInputBindings(current, device, defaults));
|
||||
cancelEdit(); setMessage(`${device === "pc" ? "PC" : "Controller"} defaults restored. Save changes to keep them.`);
|
||||
};
|
||||
const actions: MenuAction[] = pending ? [
|
||||
{ id: "keep", run: cancelEdit }, { id: "replace", run: () => apply(pending.id, pending.key) },
|
||||
] : resetPending ? [{ id: "keep", run: cancelEdit }, { id: "reset-confirm", run: restoreDefaults }]
|
||||
: editing ? [{ id: "capture", run: startCapture }, { id: "clear", run: () => apply(editing, null) }, { id: "keep", run: cancelEdit }]
|
||||
: [
|
||||
{ id: "tab-pc", run: () => setDevice("pc") }, { id: "tab-controller", run: () => setDevice("controller") },
|
||||
{ id: "movement", run: restoreMovement }, { id: "conflicts", run: () => setConflictsOnly(!conflictsOnly) },
|
||||
...shown.map((entry) => ({ id: `edit-${entry.id}`, run: () => editBinding(entry.id) })),
|
||||
{ id: "reset", run: () => setResetPending(true) }, { id: "close", run: close }, { id: "save", run: save },
|
||||
];
|
||||
const controller = useMenuController(actions, { onBack: () => editing || resetPending ? cancelEdit() : close() });
|
||||
const button = (id: string, label: string, run: () => void, className = "") => <ControllerButton
|
||||
key={id} controlId={id} selectedId={controller.selectedId} select={controller.select} data-control-id={id}
|
||||
type="button" aria-label={label} className={`button ${className}`} onClick={run}>{className.includes("keybinds-change") ? "Change" : label}</ControllerButton>;
|
||||
|
||||
useEffect(() => {
|
||||
const selected = [...(dialog.current?.querySelectorAll<HTMLElement>("[data-control-id]") ?? [])].find((element) => element.dataset.controlId === controller.selectedId);
|
||||
selected?.scrollIntoView({ block: "nearest" });
|
||||
}, [controller.selectedId]);
|
||||
useEffect(() => () => setCapturingInput(false), []);
|
||||
useEffect(() => {
|
||||
if (!listening || !editing) return;
|
||||
let captured = false;
|
||||
const ignored = new Set(getControllerHeldTokens());
|
||||
const accept = (key: string | null) => { if (!key || captured) return; captured = true; offer(key); };
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
if (event.repeat) return;
|
||||
if (event.code === "Escape") { endCapture(); return; }
|
||||
if (device === "pc") accept(inputKeyForEvent(event));
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
const releaseController = interceptControllerInput(({ token, pressed, repeat }) => {
|
||||
if (pressed === false) {
|
||||
const wasIgnored = ignored.delete(token);
|
||||
if (!wasIgnored && device === "controller" && !captured && ["Button4", "Button6"].includes(token)) accept(token);
|
||||
return true;
|
||||
}
|
||||
if (repeat || ignored.has(token)) return true;
|
||||
if (token === "SystemBack") { endCapture(); return true; }
|
||||
if (device !== "controller" || ["Button4", "Button6"].includes(token)) return true;
|
||||
const held = [...getControllerHeldTokens()].filter((part) => part !== token && !ignored.has(part) && part.startsWith("Button"));
|
||||
accept(normalizeInputBinding("controller", [...held, token].join("&")));
|
||||
return true;
|
||||
});
|
||||
const releaseMotion = subscribeControllerMovement((motion) => {
|
||||
if (device !== "controller") return;
|
||||
const axes = [motion.moveX, motion.moveY, motion.lookX, motion.lookY];
|
||||
const index = axes.findIndex((axis) => Math.abs(axis) > .65);
|
||||
if (index >= 0) accept(`Axis${index}${axes[index] > 0 ? "+" : "-"}`);
|
||||
});
|
||||
return () => { window.removeEventListener("keydown", onKey, true); releaseController(); releaseMotion(); };
|
||||
}, [listening, editing, device]);
|
||||
useEffect(() => {
|
||||
if (listening) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Escape") return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
if (editing || resetPending) cancelEdit(); else close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [listening, editing, resetPending]);
|
||||
|
||||
const manualBinding = device === "pc" ? normalizeInputBinding(device, [...modifiers, manualKey].join("+"))
|
||||
: normalizeInputBinding(device, [controllerModifier, manualKey].filter(Boolean).join("&"));
|
||||
return <div className="modal-backdrop game-panel-backdrop keybinds-backdrop">
|
||||
<section ref={dialog} className="game-panel keybinds-panel" role="dialog" aria-modal="true" aria-labelledby="keybinds-title">
|
||||
<header className="game-panel__header"><div><p className="eyebrow">Your controls</p><h2 id="keybinds-title">Keybinds</h2></div>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Close keybinds">×</button></header>
|
||||
<div className="keybinds-toolbar" inert={editing !== null || resetPending}>
|
||||
<div className="keybinds-tabs" role="tablist" aria-label="Input type">{(["pc", "controller"] as const).map((tab) => <ControllerButton
|
||||
key={tab} controlId={`tab-${tab}`} selectedId={controller.selectedId} select={controller.select} type="button" role="tab"
|
||||
aria-selected={device === tab} onClick={() => setDevice(tab)}>{tab === "pc" ? "PC" : "Controller"}</ControllerButton>)}</div>
|
||||
<p>Both input types work on every device. Changes apply when saved.</p>
|
||||
<div className="keybinds-filters"><label>Search actions or keys<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Try W, jump, or a skill name" /></label>
|
||||
<label>Category<select value={group} onChange={(event) => setGroup(event.target.value)}>{["All actions", ...new Set(INPUT_ACTIONS.map((entry) => entry.group))].map((value) => <option key={value}>{value}</option>)}</select></label></div>
|
||||
<div className="keybinds-tools">{button("movement", "Restore movement", restoreMovement)}
|
||||
{button("conflicts", `${conflictsOnly ? "Show all actions" : "Show conflicts"} (${conflicts.length})`, () => setConflictsOnly(!conflictsOnly))}
|
||||
<small>{shown.length} actions shown</small></div>
|
||||
</div>
|
||||
<div className="keybinds-list" inert={editing !== null || resetPending}>
|
||||
{conflicts.length > 0 && <p className="keybinds-warning">Conflicting controls need attention. Movement takes priority over old conflicting ability bindings.</p>}
|
||||
{shown.map((entry, index) => <div key={entry.id}>
|
||||
{(index === 0 || shown[index - 1].group !== entry.group) && <h3>{entry.group}</h3>}
|
||||
<div className={`keybinds-row ${conflicts.some((conflict) => conflict.id === entry.id) ? "has-conflict" : ""}`}>
|
||||
<span><strong>{entry.label}</strong>{detail(entry.id) && <small>{detail(entry.id)}</small>}
|
||||
{entry.group === "WoW action bars" && <small>{draft.current.pc.wowBars && Number(entry.id.split(":")[1]) < draft.current.pc.barCount ? "PC WoW layout" : "Inactive until this PC bar is enabled"}</small>}
|
||||
{conflicts.some((conflict) => conflict.id === entry.id) && <small className="keybinds-warning">Also used by {conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}</small>}</span>
|
||||
<kbd>{inputBindingLabel(device, bindings[entry.id])}</kbd>
|
||||
{button(`edit-${entry.id}`, `Change ${entry.label}`, () => editBinding(entry.id), "keybinds-change")}
|
||||
</div>
|
||||
</div>)}
|
||||
{!shown.length && <p>No matching actions.</p>}
|
||||
<p className="keybinds-note">Point and click / tap selects a target. Hold right mouse and drag to turn the camera. Android system Back remains available for navigation. Ability slots cast the skills assigned in Ability controls; WoW bar keys follow the loaded PC UI template.</p>
|
||||
</div>
|
||||
{(editing || resetPending) && <div className="keybinds-capture" role="group" aria-label="Change binding">
|
||||
{pending ? <><h3>Replace conflicting bindings?</h3><p><kbd>{inputBindingLabel(device, pending.key)}</kbd> is already assigned to {pending.conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}.</p>
|
||||
<p>Replacing will unbind those actions and assign it to {INPUT_ACTION_BY_ID[pending.id].label}.</p>
|
||||
<div>{button("keep", "Keep existing bindings", cancelEdit)}{button("replace", "Replace bindings", () => apply(pending.id, pending.key))}</div></>
|
||||
: resetPending ? <><h3>Restore {device === "pc" ? "PC" : "Controller"} defaults?</h3><p>This resets this input type, including its action-bar keys. Assigned skills stay in place.</p>
|
||||
{button("keep", "Keep my bindings", cancelEdit)}{button("reset-confirm", "Restore defaults", restoreDefaults)}</>
|
||||
: editing && <><h3>{INPUT_ACTION_BY_ID[editing].label}</h3><p>Current: {inputBindingLabel(device, bindings[editing])}</p>
|
||||
{listening ? <><p role="status">{device === "pc" ? "Press a key or modifier + key…" : "Press a controller button, hold L1/L2 and press a button, or move a stick…"}</p><p>Escape cancels capture. Use the selectors below to bind Escape or any controller button.</p><button type="button" onClick={endCapture}>Stop listening</button></>
|
||||
: button("capture", device === "pc" ? "Listen for key" : "Listen for controller", startCapture)}
|
||||
<div className="keybinds-manual"><p>Or choose a binding using touch or mouse:</p>
|
||||
{device === "pc" ? <div className="keybinds-modifiers">{["Ctrl", "Alt", "Shift", "Meta"].map((modifier) => <label key={modifier}><input type="checkbox" checked={modifiers.includes(modifier)} onChange={(event) => setModifiers((current) => event.target.checked ? [...current, modifier] : current.filter((part) => part !== modifier))} />{modifier}</label>)}</div>
|
||||
: <label>Hold modifier<select value={controllerModifier} onChange={(event) => setControllerModifier(event.target.value)}><option value="">None</option>{CONTROLLER_BINDING_CHOICES.filter((key) => key.startsWith("Button")).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>}
|
||||
<label>{device === "pc" ? "Key" : "Button or stick direction"}<select value={manualKey} onChange={(event) => setManualKey(event.target.value)}>{(device === "pc" ? PC_KEYS : CONTROLLER_BINDING_CHOICES).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>
|
||||
<button type="button" disabled={!manualBinding} onClick={() => manualBinding && offer(manualBinding)}>Use selected binding</button>
|
||||
</div><div>{button("clear", "Clear binding", () => apply(editing, null))}{button("keep", "Cancel binding change", cancelEdit)}</div></>}
|
||||
</div>}
|
||||
<footer className="keybinds-footer" inert={editing !== null || resetPending}>
|
||||
<p role="status">{message || (online ? "Saved controls sync with your account and are included in UI exports." : "Offline controls save to this profile. UI export/import transfers them to another device.")}</p>
|
||||
{online && <p role="status">{cloud.message}{cloud.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
|
||||
<div>{button("reset", `Reset ${device === "pc" ? "PC" : "Controller"} defaults`, () => setResetPending(true))}
|
||||
{button("close", "Done", close)}{button("save", "Save changes", save, "button--primary")}</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
export function KeybindsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "keybinds");
|
||||
return open ? <KeybindsDialog /> : null;
|
||||
}
|
||||
+3
-1
@@ -1,3 +1,4 @@
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController } from "../input/useMenuController";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
@@ -6,6 +7,7 @@ import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
|
||||
function MapDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const close = useGameStore((state) => state.closeMap);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const position = useGameStore((state) => state.playerPosition);
|
||||
@@ -44,7 +46,7 @@ function MapDialog() {
|
||||
{stagePresentation.usingFallbackEnvironment
|
||||
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
|
||||
: "Gold markers show encounters. Dashed lines show database patrol routes. "}
|
||||
Press M to close or B to return to Pause.
|
||||
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+6
-1
@@ -1,6 +1,8 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Vector3Tuple } from "../game/dungeonManifest";
|
||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export interface MinimapProps {
|
||||
position: Vector3Tuple;
|
||||
@@ -19,6 +21,9 @@ export function Minimap({
|
||||
rotationMode = "north-up",
|
||||
onOpenMap,
|
||||
}: MinimapProps) {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const inputMode = useGameStore((state) => state.inputMode);
|
||||
const mapKey = bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "map");
|
||||
const heading = ((Math.round((yaw * 180) / Math.PI) % 360) + 360) % 360;
|
||||
return (
|
||||
<section className="hud-minimap" aria-label="Dungeon minimap">
|
||||
@@ -35,7 +40,7 @@ export function Minimap({
|
||||
</button>
|
||||
<div className="hud-minimap__caption">
|
||||
<strong>{areaName}</strong>
|
||||
<span>{Math.round(distanceTravelled)} m explored · <kbd>M</kbd> map</span>
|
||||
<span>{Math.round(distanceTravelled)} m explored · {mapKey !== "Unbound" && <kbd>{mapKey}</kbd>} map</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, type CombatVfxMode } from "../gam
|
||||
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
|
||||
import { OptionsPanel, nextCombatVfxMode } from "./OptionsPanel";
|
||||
|
||||
function renderOptions(mode: CombatVfxMode, originalMediaEnabled = false): string {
|
||||
function renderOptions(mode: CombatVfxMode, originalMediaEnabled = DEFAULT_COMBAT_PRESENTATION_SETTINGS.originalMediaEnabled): string {
|
||||
return renderToStaticMarkup(createElement(OptionsPanel, {
|
||||
settings: DEFAULT_GAMEPLAY_SETTINGS,
|
||||
onChange: () => undefined,
|
||||
@@ -31,28 +31,29 @@ describe("combat visual mode option", () => {
|
||||
combatMedia.ready = false;
|
||||
});
|
||||
|
||||
it("cycles between generated and strict original selections", () => {
|
||||
it("allows an explicit generated override of the original-first default", () => {
|
||||
expect(nextCombatVfxMode("generated")).toBe("original");
|
||||
expect(nextCombatVfxMode("original")).toBe("generated");
|
||||
});
|
||||
|
||||
it("renders generated as the default selection", () => {
|
||||
it("renders original as the default selection while media is unavailable", () => {
|
||||
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
|
||||
|
||||
expect(markup).toContain("Spell visual mode");
|
||||
expect(markup).toContain("Original spell effects & sounds");
|
||||
expect(markup).toContain("Not downloaded by default.");
|
||||
expect(markup).toContain("download automatically when enabled.");
|
||||
expect(markup).toContain(">Download</button>");
|
||||
expect(markup).toContain("Generated effects are used until the optional original media pack is downloaded.");
|
||||
expect(markup).toContain(">Generated</button>");
|
||||
expect(markup).toContain("Generated visuals are the backup while original media is unavailable.");
|
||||
expect(markup).toContain("missing sounds stay silent.");
|
||||
expect(markup).toContain(">Original</button>");
|
||||
});
|
||||
|
||||
it("states that original mode never substitutes generated effects", () => {
|
||||
it("explains original effects take priority and generated visuals are only a fallback", () => {
|
||||
combatMedia.ready = true;
|
||||
const markup = renderOptions("original", true);
|
||||
|
||||
expect(markup).toContain("Use only packaged original source effects.");
|
||||
expect(markup).toContain("Unsupported spells show no visual effect.");
|
||||
expect(markup).toContain("Use original spell effects first, with generated visuals only when an original effect is unavailable.");
|
||||
expect(markup).toContain("Missing sounds stay silent.");
|
||||
expect(markup).toContain(">Original</button>");
|
||||
expect(markup).toContain(">Remove</button>");
|
||||
});
|
||||
|
||||
+11
-7
@@ -123,13 +123,13 @@ function OptionsDialog({
|
||||
const toggleOriginalMedia = () => {
|
||||
if (combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing") return;
|
||||
if (combatMediaInstalled && !presentationSettings.originalMediaEnabled) {
|
||||
onPresentationChange({ originalMediaEnabled: true });
|
||||
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
|
||||
return;
|
||||
}
|
||||
if (combatMediaInstalled) {
|
||||
setCombatMediaAction({ kind: "removing" });
|
||||
void removeCombatMediaPack().then(() => {
|
||||
onPresentationChange({ originalMediaEnabled: false, vfxMode: "generated" });
|
||||
onPresentationChange({ originalMediaEnabled: false });
|
||||
setCombatMediaAction({ kind: "idle" });
|
||||
}).catch((error: unknown) => setCombatMediaAction({
|
||||
kind: "error",
|
||||
@@ -141,7 +141,7 @@ function OptionsDialog({
|
||||
void downloadCombatMediaPack(({ completed, total }) => {
|
||||
setCombatMediaAction({ kind: "downloading", completed, total });
|
||||
}).then(() => {
|
||||
onPresentationChange({ originalMediaEnabled: true });
|
||||
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
|
||||
setCombatMediaAction({ kind: "idle" });
|
||||
}).catch((error: unknown) => setCombatMediaAction({
|
||||
kind: "error",
|
||||
@@ -164,6 +164,7 @@ function OptionsDialog({
|
||||
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
|
||||
});
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "option-keybinds", run: () => useGameStore.getState().openOverlay("keybinds") },
|
||||
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
|
||||
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
|
||||
...TOGGLES.map((option) => ({
|
||||
@@ -221,6 +222,9 @@ function OptionsDialog({
|
||||
</header>
|
||||
|
||||
<div className="options-panel__list">
|
||||
<div className="option-row"><span><strong>Keybinds</strong><small>View and change every PC and Controller action. Resolve conflicts or restore movement.</small></span>
|
||||
{optionButton("option-keybinds", "Keybinds", () => useGameStore.getState().openOverlay("keybinds"))}
|
||||
</div>
|
||||
<h3 className="options-panel__section-title">Performance</h3>
|
||||
<div className="option-row">
|
||||
<span><strong>Gameplay frame rate</strong><small>Limit 3D gameplay rendering without changing combat or physics timing.</small></span>
|
||||
@@ -286,10 +290,10 @@ function OptionsDialog({
|
||||
<span>
|
||||
<strong>Original spell effects & sounds</strong>
|
||||
<small>{originalMediaReady
|
||||
? "Stored on this device. Original visuals and authentic combat sounds are available."
|
||||
? "Stored on this device. Original spell effects and sounds are ready. Missing sounds stay silent."
|
||||
: combatMediaInstalled
|
||||
? "Stored on this device. Enable the pack to use original visuals and authentic combat sounds."
|
||||
: "Not downloaded by default. Download this optional media pack to unlock original visuals and authentic combat sounds."}</small>
|
||||
: "Original spell effects and sounds download automatically when enabled. Generated visuals cover missing effects; missing sounds stay silent."}</small>
|
||||
{combatMediaAction.kind === "error"
|
||||
? <small role="status">{combatMediaAction.message}</small>
|
||||
: null}
|
||||
@@ -305,9 +309,9 @@ function OptionsDialog({
|
||||
<span>
|
||||
<strong>Spell visual mode</strong>
|
||||
<small>{!originalMediaReady
|
||||
? "Generated effects are used until the optional original media pack is downloaded."
|
||||
? "Original effects are preferred by default. Generated visuals are the backup while original media is unavailable."
|
||||
: presentationSettings.vfxMode === "original"
|
||||
? "Use only packaged original source effects. Unsupported spells show no visual effect."
|
||||
? "Use original spell effects first, with generated visuals only when an original effect is unavailable."
|
||||
: "Use the current generated combat effects."}</small>
|
||||
</span>
|
||||
{optionButton(
|
||||
|
||||
+10
-11
@@ -4,8 +4,11 @@ import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const reset = useGameStore((state) => state.resetAtEntrance);
|
||||
@@ -41,6 +44,7 @@ function PauseDialog() {
|
||||
{ id: "pause-spellbook", run: () => openOverlay("spellbook") },
|
||||
{ id: "pause-bindings", run: () => openOverlay("bindings") },
|
||||
{ id: "pause-talents", run: () => openOverlay("talents") },
|
||||
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
@@ -77,6 +81,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-spellbook", "Spellbook", () => openOverlay("spellbook"))}
|
||||
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
|
||||
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
|
||||
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
@@ -86,17 +91,11 @@ function PauseDialog() {
|
||||
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
|
||||
</div>
|
||||
<dl className="controls-list">
|
||||
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
|
||||
<div><dt>Jump</dt><dd>Space / L3</dd></div>
|
||||
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
|
||||
<div><dt>Abilities</dt><dd>1–8 / Face, R1, R2, D-pad left/right</dd></div>
|
||||
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
|
||||
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
|
||||
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
|
||||
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
|
||||
<div><dt>Boss guide</dt><dd>Pause menu</dd></div>
|
||||
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
|
||||
<div><dt>Pause</dt><dd>Escape / Start</dd></div>
|
||||
{["move-forward", "jump", "target-next", "party-next", "loot", "inventory", "spellbook", "pause"].map((id) => <div key={id}>
|
||||
<dt>{INPUT_ACTION_BY_ID[id].label}</dt><dd>{bindingLabel("pc", id)} / {bindingLabel("controller", id)}</dd>
|
||||
</div>)}
|
||||
<div><dt>Camera</dt><dd>Hold right mouse and drag</dd></div>
|
||||
<div><dt>All controls</dt><dd>Options → Keybinds</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ function treeSigil(name: string): string {
|
||||
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function TalentGameplayPanel() {
|
||||
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
@@ -42,7 +42,8 @@ export default function TalentGameplayPanel() {
|
||||
availablePoints={runtimeUnspentTalentPoints(level, talentRanks, classId)}
|
||||
abilityEssence={coaEssence?.ability}
|
||||
talentEssence={coaEssence?.talent}
|
||||
onInvest={(nodeId) => { useCombatStore.getState().allocateTalent(nodeId); }}
|
||||
onReset={() => useCombatStore.getState().resetTalents()}
|
||||
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
|
||||
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
|
||||
readOnly={readOnly}
|
||||
/>;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface TalentTreeView {
|
||||
}
|
||||
|
||||
export interface TalentsPanelProps {
|
||||
readOnly?: boolean;
|
||||
trees: readonly TalentTreeView[];
|
||||
ranks: TalentRanks;
|
||||
classId: ClassId;
|
||||
@@ -109,6 +110,7 @@ function TalentsDialog({
|
||||
onInvest,
|
||||
onReset,
|
||||
onClose,
|
||||
readOnly = false,
|
||||
}: Omit<TalentsPanelProps, "open" | "onClose"> & { onClose: () => void }) {
|
||||
const [activeTreeId, setActiveTreeId] = useState(() => trees[0]?.id ?? "");
|
||||
const [detailNodeId, setDetailNodeId] = useState(() => trees[0]?.nodes[0]?.id ?? "");
|
||||
@@ -167,7 +169,7 @@ function TalentsDialog({
|
||||
id: nodeControlId(node.id),
|
||||
run: () => {
|
||||
setDetailNodeId(node.id);
|
||||
if (!state?.failure) onInvest(node.id);
|
||||
if (!readOnly && !state?.failure) onInvest(node.id);
|
||||
},
|
||||
neighbors: {
|
||||
up: up ? nodeControlId(up.id) : tabControlId(activeTreeId),
|
||||
@@ -180,7 +182,7 @@ function TalentsDialog({
|
||||
return [
|
||||
...tabActions,
|
||||
...nodeActions,
|
||||
{ id: "talent-reset", run: onReset, enabled: spent > 0 },
|
||||
{ id: "talent-reset", run: onReset, enabled: !readOnly && spent > 0 },
|
||||
];
|
||||
}, [activeNodes, activeTreeId, nodeStateById, onInvest, onReset, spent, trees]);
|
||||
const controller = useMenuController(actions, {
|
||||
@@ -219,7 +221,7 @@ function TalentsDialog({
|
||||
|
||||
const activateNode = (treeId: string, node: TalentUiNode) => {
|
||||
inspectNode(treeId, node.id);
|
||||
if (!nodeStateById.get(node.id)?.failure) onInvest(node.id);
|
||||
if (!readOnly && !nodeStateById.get(node.id)?.failure) onInvest(node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -333,7 +335,7 @@ function TalentsDialog({
|
||||
className={`talent-node ${learned ? "is-learned" : ""} ${maximum ? "is-max" : ""} ${locked ? "is-locked" : ""} ${unavailable ? "is-unavailable" : ""} ${detailNode?.id === node.id ? "is-inspected" : ""}`}
|
||||
style={{ gridColumn: node.column, gridRow: node.row } as CSSProperties}
|
||||
aria-label={label}
|
||||
aria-disabled={state.failure !== null}
|
||||
aria-disabled={readOnly || state.failure !== null}
|
||||
aria-describedby={detailNode?.id === node.id ? "talent-node-details" : undefined}
|
||||
title={`${label} — ${talentUiDescriptionForRank(node, state.rank || 1)}`}
|
||||
onFocus={() => inspectNode(tree.id, node.id)}
|
||||
@@ -429,7 +431,7 @@ function TalentsDialog({
|
||||
select={controller.select}
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={spent <= 0}
|
||||
disabled={readOnly || spent <= 0}
|
||||
onClick={onReset}
|
||||
>Reset talents</ControllerButton>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
||||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||||
} from "../game/uiLayout";
|
||||
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
|
||||
import { profileInputBindings } from "../game/keybindPreferences";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
|
||||
export function UiLayoutEditor() {
|
||||
@@ -52,6 +54,12 @@ export function UiLayoutEditor() {
|
||||
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
|
||||
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
|
||||
const { bar, slot: button } = state.selectedButton;
|
||||
const bindings = profileInputBindings(state.preferences, "pc");
|
||||
const conflicts = conflictingInputActions(bindings, "pc", `bar:${bar}:${button}`, key).filter((id) => !id.startsWith("bar:"));
|
||||
if (conflicts.length) {
|
||||
setMessage(`That key belongs to ${conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}. Change it in Options → Keybinds first. Your existing bindings were kept.`);
|
||||
useUiLayoutStore.setState({ selectedButton: null }); return;
|
||||
}
|
||||
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
|
||||
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
|
||||
useUiLayoutStore.setState({ selectedButton: null });
|
||||
@@ -118,7 +126,7 @@ export function UiLayoutEditor() {
|
||||
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
|
||||
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
|
||||
</div>}
|
||||
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots and both saved layouts with this file?</p><button type="button" onClick={() => attempt(() => {
|
||||
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots, both saved layouts, and profile keybinds with this file?</p><button type="button" onClick={() => attempt(() => {
|
||||
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
|
||||
})}>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>
|
||||
@@ -144,7 +152,7 @@ export function UiLayoutEditor() {
|
||||
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
|
||||
</div>)}
|
||||
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
|
||||
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Bound keys take priority over game shortcuts." : "Click a bar button to assign an ability."}</p>
|
||||
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Keys already used by game controls are protected. Review all bindings in Options → Keybinds." : "Click a bar button to assign an ability."}</p>
|
||||
{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>
|
||||
|
||||
@@ -91,6 +91,8 @@ export async function preloadSharedGameplayResources(originalMediaEnabled: boole
|
||||
loadCompanionManastormPanel(),
|
||||
]);
|
||||
if (!originalMediaEnabled) return;
|
||||
const { ensureCombatMediaPack } = await import("../game/combatMediaPack");
|
||||
if (!await ensureCombatMediaPack()) return;
|
||||
const { preloadCombatEffectManifest } = await import("../game/combatPresentation");
|
||||
await preloadCombatEffectManifest();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.keybinds-backdrop { padding: 0; }
|
||||
.keybinds-panel { width: min(1000px, 96vw); height: min(900px, 94dvh); max-height: 94dvh; padding: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
||||
.keybinds-panel .game-panel__header { padding: 16px 20px 6px; flex-shrink: 0; }
|
||||
.keybinds-panel button, .keybinds-panel select, .keybinds-panel input { min-height: 42px; }
|
||||
.keybinds-toolbar, .keybinds-footer { flex-shrink: 0; padding: 12px 20px; }
|
||||
.keybinds-toolbar p, .keybinds-footer p { margin: 6px 0; font-size: 13px; color: #b7c7b5; }
|
||||
.keybinds-tabs, .keybinds-tools, .keybinds-footer > div, .keybinds-capture > div { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.keybinds-tabs button { flex: 1; border: 1px solid #5a6846; padding: 8px 18px; background: #14231b; color: #dfe9d6; border-radius: 6px; }
|
||||
.keybinds-tabs button[aria-selected="true"] { color: #ffe4a0; background: #344531; border-color: #d0ae65; }
|
||||
.keybinds-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 10px 0; }
|
||||
.keybinds-filters label, .keybinds-manual > label { display: grid; gap: 5px; font-size: 12px; color: #c6d0c0; }
|
||||
.keybinds-panel select, .keybinds-panel input:not([type="checkbox"]) { width: 100%; min-width: 0; border: 1px solid #56674c; background: #0e1a14; color: #f0eada; border-radius: 5px; padding: 8px; }
|
||||
.keybinds-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 20px 12px; overscroll-behavior: contain; }
|
||||
.keybinds-list h3 { color: #dcc88e; padding: 12px 0 7px; margin: 0; font-size: 14px; }
|
||||
.keybinds-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(90px, 210px) 90px; align-items: center; gap: 14px; border-top: 1px solid #344432; padding: 9px 0; }
|
||||
.keybinds-row strong { font-size: 14px; }
|
||||
.keybinds-row small { display: block; font-size: 11px; margin-top: 4px; color: #aabb9f; }
|
||||
.keybinds-row kbd { white-space: normal; overflow-wrap: anywhere; text-align: center; padding: 5px 8px; color: #e6d8b5; background: #14221a; }
|
||||
.keybinds-change { font-size: 13px; }
|
||||
.keybinds-warning, .keybinds-row .keybinds-warning { color: #f0ba79; }
|
||||
.keybinds-row.has-conflict { border-left: 3px solid #dc9d56; padding-left: 9px; }
|
||||
.keybinds-note { font-size: 12px; line-height: 1.6; color: #aabb9f; }
|
||||
.keybinds-footer { border-top: 1px solid #586041; background: #111e16; }
|
||||
.keybinds-footer > div { justify-content: flex-end; margin-top: 10px; }
|
||||
.keybinds-capture { position: absolute; z-index: 4; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(560px, 92%); max-height: 90%; overflow: auto; padding: 24px; border: 1px solid #d0ae65; border-radius: 12px; background: #14221b; box-shadow: 0 0 0 100vmax #0009; }
|
||||
.keybinds-capture h3 { margin-top: 0; color: #ffe4a0; }
|
||||
.keybinds-capture p { line-height: 1.5; }
|
||||
.keybinds-capture button { padding: 8px 12px; border: 1px solid #6b7555; border-radius: 6px; background: #253826; color: #f0eada; cursor: pointer; }
|
||||
.keybinds-capture .keybinds-manual { display: grid; gap: 10px; margin: 18px 0; border-top: 1px solid #5a6846; padding-top: 12px; }
|
||||
.keybinds-modifiers { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.keybinds-modifiers label { display: flex; align-items: center; gap: 5px; }
|
||||
.keybinds-panel :focus-visible { outline: 2px solid #ffe4a0; outline-offset: 2px; }
|
||||
@media (max-width: 600px) {
|
||||
.keybinds-panel { width: 100vw; height: 100dvh; max-height: 100dvh; border-radius: 0; }
|
||||
.keybinds-toolbar, .keybinds-footer, .keybinds-list { padding-left: 12px; padding-right: 12px; }
|
||||
.keybinds-toolbar { padding-top: 6px; padding-bottom: 6px; }
|
||||
.keybinds-row { grid-template-columns: minmax(0, 1fr) 105px 64px; gap: 8px; }
|
||||
.keybinds-row strong { font-size: 12px; }
|
||||
.keybinds-row kbd { font-size: 11px; }
|
||||
.keybinds-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.keybinds-footer .button { padding: 8px; font-size: 11px; }
|
||||
.keybinds-footer p { font-size: 11px; }
|
||||
.keybinds-capture { padding: 16px; }
|
||||
}
|
||||
@media (max-height: 600px) {
|
||||
.keybinds-panel { height: 100dvh; max-height: 100dvh; }
|
||||
.keybinds-toolbar > p, .keybinds-footer > p:empty { display: none; }
|
||||
.keybinds-toolbar, .keybinds-footer { padding-top: 5px; padding-bottom: 5px; }
|
||||
.keybinds-panel .game-panel__header { padding-top: 8px; padding-bottom: 8px; }
|
||||
}
|
||||
Reference in new issue
Block a user