forked from phenom/healer-man
149 lines
7.7 KiB
TypeScript
149 lines
7.7 KiB
TypeScript
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} />;
|
||
}
|