Files
healer-man/src/ui/OptionsPanel.tsx
T
2026-08-14 15:56:39 -04:00

149 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from "react";
import { useGameStore } from "../game/store";
import {
DEFAULT_THREAT_METER_POSITION,
type NormalizedHudPosition,
} from "../game/hudLayout";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export interface OptionsPanelSettings {
showMobHealthBars: boolean;
showTargetFrame: boolean;
showThreatMeter: boolean;
threatMeterPosition: NormalizedHudPosition;
showFloatingCombatText: boolean;
autoTargetNearest: boolean;
enableScreenShake: boolean;
masterVolume: number;
uiScale: number;
minimapRotation: "north-up" | "player-up";
}
export interface OptionsPanelProps {
settings: OptionsPanelSettings;
onChange: (patch: Partial<OptionsPanelSettings>) => void;
onReset: () => void;
open?: boolean;
onClose?: () => void;
}
interface ToggleOption {
id: keyof Pick<OptionsPanelSettings,
"showMobHealthBars" | "showTargetFrame" | "showThreatMeter" | "showFloatingCombatText" | "autoTargetNearest" | "enableScreenShake">;
label: string;
description: string;
}
const TOGGLES: readonly ToggleOption[] = [
{ id: "showMobHealthBars", label: "Mob health bars", description: "Show health above nearby enemies." },
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
{ id: "showThreatMeter", label: "Threat meter", description: "Show the compact movable threat table for your selected enemy." },
{ id: "showFloatingCombatText", label: "Combat numbers", description: "Show damage, healing, and absorb feedback." },
{ id: "autoTargetNearest", label: "Automatic targeting", description: "Pick the nearest valid enemy when an offensive ability needs one." },
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
] as const;
function quantize(value: number, step: number, minimum: number, maximum: number): number {
const next = Math.round(value / step) * step;
return Math.max(minimum, Math.min(maximum, Number(next.toFixed(2))));
}
function OptionsDialog({ settings, onChange, onReset, onClose }: Required<Omit<OptionsPanelProps, "open">>) {
const actions = useMemo<MenuAction[]>(() => [
...TOGGLES.map((option) => ({
id: `option-${option.id}`,
run: () => onChange({ [option.id]: !settings[option.id] }),
})),
{ id: "option-volume-down", run: () => onChange({ masterVolume: quantize(settings.masterVolume - 0.1, 0.1, 0, 1) }) },
{ id: "option-volume-up", run: () => onChange({ masterVolume: quantize(settings.masterVolume + 0.1, 0.1, 0, 1) }) },
{ id: "option-scale-down", run: () => onChange({ uiScale: quantize(settings.uiScale - 0.05, 0.05, 0.75, 1.25) }) },
{ id: "option-scale-up", run: () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }) },
{ id: "option-minimap", run: () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up" }) },
{ id: "option-threat-position", run: () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION }) },
{ id: "option-reset", run: onReset },
], [onChange, onReset, settings]);
const controller = useMenuController(actions, { columns: 1, onBack: onClose });
const optionButton = (id: string, label: string, run: () => void, disabled = false) => (
<ControllerButton
controlId={id}
selectedId={controller.selectedId}
select={controller.select}
type="button"
disabled={disabled}
onClick={run}
>{label}</ControllerButton>
);
return (
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel options-panel" role="dialog" aria-modal="true" aria-labelledby="options-title">
<header className="game-panel__header">
<div><p className="eyebrow">Gameplay and interface</p><h2 id="options-title">Options</h2></div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
</header>
<div className="options-panel__list">
{TOGGLES.map((option) => (
<div key={option.id} className="option-row">
<span><strong>{option.label}</strong><small>{option.description}</small></span>
<ControllerButton
controlId={`option-${option.id}`}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={settings[option.id] ? "is-on" : ""}
aria-pressed={settings[option.id]}
onClick={() => onChange({ [option.id]: !settings[option.id] })}
>{settings[option.id] ? "On" : "Off"}</ControllerButton>
</div>
))}
<div className="option-row">
<span><strong>Master volume</strong><small>Overall game audio volume.</small></span>
<div className="option-stepper">
{optionButton("option-volume-down", "−", () => onChange({ masterVolume: quantize(settings.masterVolume - 0.1, 0.1, 0, 1) }), settings.masterVolume <= 0)}
<output>{Math.round(settings.masterVolume * 100)}%</output>
{optionButton("option-volume-up", "+", () => onChange({ masterVolume: quantize(settings.masterVolume + 0.1, 0.1, 0, 1) }), settings.masterVolume >= 1)}
</div>
</div>
<div className="option-row">
<span><strong>Interface scale</strong><small>Resize HUD and menu elements for either Thor display or PC.</small></span>
<div className="option-stepper">
{optionButton("option-scale-down", "−", () => onChange({ uiScale: quantize(settings.uiScale - 0.05, 0.05, 0.75, 1.25) }), settings.uiScale <= 0.75)}
<output>{Math.round(settings.uiScale * 100)}%</output>
{optionButton("option-scale-up", "+", () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }), settings.uiScale >= 1.25)}
</div>
</div>
<div className="option-row">
<span><strong>Minimap orientation</strong><small>Keep north fixed or rotate the map with your character.</small></span>
{optionButton("option-minimap", settings.minimapRotation === "north-up" ? "North up" : "Player up", () => onChange({
minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up",
}))}
</div>
<div className="option-row">
<span><strong>Threat meter position</strong><small>Drag its title bar during combat, or restore its suggested location.</small></span>
{optionButton("option-threat-position", "Reset position", () => onChange({
threatMeterPosition: DEFAULT_THREAT_METER_POSITION,
}))}
</div>
</div>
<div className="options-panel__actions">
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={onReset}>Restore defaults</ControllerButton>
<button type="button" className="button button--primary" onClick={onClose}>Done</button>
</div>
<footer className="game-panel__footer"><span><kbd>A</kbd> Change</span><span><kbd>B</kbd> Pause menu</span></footer>
</section>
</div>
);
}
export function OptionsPanel(props: OptionsPanelProps) {
const storeOpen = useGameStore((state) => state.overlay === "options");
const openOverlay = useGameStore((state) => state.openOverlay);
const close = props.onClose ?? (() => openOverlay("pause"));
if (!(props.open ?? storeOpen)) return null;
return <OptionsDialog {...props} onClose={close} />;
}