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

281 lines
14 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";
import {
type CombatActorGroup,
type CombatPresentationSettings,
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
GAMEPLAY_FRAME_RATE_LIMITS,
PREVIEW_FRAME_RATE_LIMITS,
frameRateLimitLabel,
nextFrameRateLimit,
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "../game/frameRateSettings";
export interface OptionsPanelSettings {
showMobHealthBars: boolean;
showMobAggroRanges: boolean;
showTargetFrame: boolean;
showThreatMeter: boolean;
threatMeterPosition: NormalizedHudPosition;
showFloatingCombatText: boolean;
autoTargetNearest: boolean;
enableScreenShake: boolean;
masterVolume: number;
uiScale: number;
minimapRotation: "north-up" | "player-up";
gameplayFrameRate: GameplayFrameRateLimit;
previewFrameRate: PreviewFrameRateLimit;
}
export interface OptionsPanelProps {
settings: OptionsPanelSettings;
onChange: (patch: Partial<OptionsPanelSettings>) => void;
onReset: () => void;
presentationSettings: CombatPresentationSettings;
onPresentationChange: (patch: Partial<CombatPresentationSettings>) => void;
onPresentationReset: () => void;
open?: boolean;
onClose?: () => void;
}
interface ToggleOption {
id: keyof Pick<OptionsPanelSettings,
"showMobHealthBars" | "showMobAggroRanges" | "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: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
{ 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;
const COMBAT_ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
const VFX_QUALITY_ORDER: readonly CombatVfxQuality[] = ["off", "low", "high"];
function actorGroupLabel(group: CombatActorGroup): string {
return group === "enemy" ? "Enemies" : `${group[0].toUpperCase()}${group.slice(1)}`;
}
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,
presentationSettings,
onPresentationChange,
onPresentationReset,
onClose,
}: Required<Omit<OptionsPanelProps, "open">>) {
const patchGroupBoolean = (
field: "vfxEnabled" | "audioEnabled",
group: CombatActorGroup,
value: boolean,
) => onPresentationChange({ [field]: { ...presentationSettings[field], [group]: value } });
const patchGroupVolume = (group: CombatActorGroup, value: number) => onPresentationChange({
audioVolume: { ...presentationSettings.audioVolume, [group]: quantize(value, 0.1, 0, 1) },
});
const cycleVfxQuality = () => {
const index = VFX_QUALITY_ORDER.indexOf(presentationSettings.vfxQuality);
onPresentationChange({ vfxQuality: VFX_QUALITY_ORDER[(index + 1) % VFX_QUALITY_ORDER.length] });
};
const cycleGameplayFrameRate = () => onChange({
gameplayFrameRate: nextFrameRateLimit(settings.gameplayFrameRate, GAMEPLAY_FRAME_RATE_LIMITS),
});
const cyclePreviewFrameRate = () => onChange({
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
});
const actions = useMemo<MenuAction[]>(() => [
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
...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-vfx-quality", run: cycleVfxQuality },
...COMBAT_ACTOR_GROUPS.flatMap((group) => [
{
id: `option-vfx-${group}`,
run: () => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group]),
},
{
id: `option-audio-${group}`,
run: () => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group]),
},
{
id: `option-audio-${group}-down`,
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1),
},
{
id: `option-audio-${group}-up`,
run: () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1),
},
]),
{ id: "option-reset", run: () => { onReset(); onPresentationReset(); } },
], [onChange, onPresentationChange, onPresentationReset, onReset, presentationSettings, 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">
<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>
{optionButton(
"option-gameplay-frame-rate",
frameRateLimitLabel(settings.gameplayFrameRate),
cycleGameplayFrameRate,
)}
</div>
<div className="option-row">
<span><strong>Character preview frame rate</strong><small>Limit animated character previews shown in menus.</small></span>
{optionButton(
"option-preview-frame-rate",
frameRateLimitLabel(settings.previewFrameRate),
cyclePreviewFrameRate,
)}
</div>
<h3 className="options-panel__section-title">Gameplay and interface</h3>
{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>
<h3 className="options-panel__section-title">Combat presentation</h3>
<div className="option-row">
<span><strong>VFX quality</strong><small>High uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX.</small></span>
{optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
</div>
{COMBAT_ACTOR_GROUPS.map((group) => (
<div className="options-panel__combat-group" key={group}>
<div className="option-row">
<span><strong>{actorGroupLabel(group)} VFX</strong><small>Show attacks, spells, trails, impacts, and auras from this group.</small></span>
<ControllerButton
controlId={`option-vfx-${group}`}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={presentationSettings.vfxEnabled[group] ? "is-on" : ""}
aria-pressed={presentationSettings.vfxEnabled[group]}
onClick={() => patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group])}
>{presentationSettings.vfxEnabled[group] ? "On" : "Off"}</ControllerButton>
</div>
<div className="option-row">
<span><strong>{actorGroupLabel(group)} combat audio</strong><small>Mute or mix ability and weapon sounds from this group.</small></span>
<div className="option-audio-controls">
<ControllerButton
controlId={`option-audio-${group}`}
selectedId={controller.selectedId}
select={controller.select}
type="button"
className={presentationSettings.audioEnabled[group] ? "is-on" : ""}
aria-pressed={presentationSettings.audioEnabled[group]}
onClick={() => patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group])}
>{presentationSettings.audioEnabled[group] ? "On" : "Muted"}</ControllerButton>
<div className="option-stepper">
{optionButton(`option-audio-${group}-down`, "−", () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1), presentationSettings.audioVolume[group] <= 0)}
<output>{Math.round(presentationSettings.audioVolume[group] * 100)}%</output>
{optionButton(`option-audio-${group}-up`, "+", () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1), presentationSettings.audioVolume[group] >= 1)}
</div>
</div>
</div>
</div>
))}
</div>
<div className="options-panel__actions">
<ControllerButton controlId="option-reset" selectedId={controller.selectedId} select={controller.select} type="button" className="button" onClick={() => { onReset(); onPresentationReset(); }}>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} />;
}