Files
healer-man/src/ui/OptionsPanel.tsx
T
2026-09-20 21:50:50 -04:00

382 lines
19 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, useState } 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 CombatVfxMode,
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
GAMEPLAY_FRAME_RATE_LIMITS,
PREVIEW_FRAME_RATE_LIMITS,
frameRateLimitLabel,
nextFrameRateLimit,
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "../game/frameRateSettings";
import {
combatMediaPackReady,
downloadCombatMediaPack,
removeCombatMediaPack,
} from "../game/combatMediaPack";
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: "Party healing numbers", description: "Float effective healing amounts over party frames during combat." },
{ 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"];
type CombatMediaActionState =
| { readonly kind: "idle" }
| { readonly kind: "downloading"; readonly completed: number; readonly total: number }
| { readonly kind: "removing" }
| { readonly kind: "error"; readonly message: string };
export function nextCombatVfxMode(mode: CombatVfxMode): CombatVfxMode {
return mode === "generated" ? "original" : "generated";
}
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 [combatMediaAction, setCombatMediaAction] = useState<CombatMediaActionState>({ kind: "idle" });
const combatMediaInstalled = combatMediaPackReady();
const originalMediaReady = presentationSettings.originalMediaEnabled && combatMediaInstalled;
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 cycleVfxMode = () => {
if (!originalMediaReady) return;
onPresentationChange({ vfxMode: nextCombatVfxMode(presentationSettings.vfxMode) });
};
const toggleOriginalMedia = () => {
if (combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing") return;
if (combatMediaInstalled && !presentationSettings.originalMediaEnabled) {
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
return;
}
if (combatMediaInstalled) {
setCombatMediaAction({ kind: "removing" });
void removeCombatMediaPack().then(() => {
onPresentationChange({ originalMediaEnabled: false });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
message: error instanceof Error ? error.message : "Could not remove the local combat media.",
}));
return;
}
setCombatMediaAction({ kind: "downloading", completed: 0, total: 1 });
void downloadCombatMediaPack(({ completed, total }) => {
setCombatMediaAction({ kind: "downloading", completed, total });
}).then(() => {
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
message: error instanceof Error ? error.message : "Could not download the combat media.",
}));
};
const combatMediaButtonLabel = combatMediaAction.kind === "downloading"
? `Downloading ${Math.round(combatMediaAction.completed / Math.max(1, combatMediaAction.total) * 100)}%`
: combatMediaAction.kind === "removing"
? "Removing…"
: combatMediaInstalled && !presentationSettings.originalMediaEnabled
? "Enable"
: combatMediaInstalled
? "Remove"
: "Download";
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-keybinds", run: () => useGameStore.getState().openOverlay("keybinds") },
{ 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-original-media", run: toggleOriginalMedia },
{ id: "option-vfx-mode", run: cycleVfxMode },
{ 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(); } },
], [combatMediaAction.kind, 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">
<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>
{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>Original spell effects &amp; sounds</strong>
<small>{originalMediaReady
? "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."
: "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}
</span>
{optionButton(
"option-original-media",
combatMediaButtonLabel,
toggleOriginalMedia,
combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing",
)}
</div>
<div className="option-row">
<span>
<strong>Spell visual mode</strong>
<small>{!originalMediaReady
? "Original effects are preferred by default. Generated visuals are the backup while original media is unavailable."
: presentationSettings.vfxMode === "original"
? "Use original spell effects first, with generated visuals only when an original effect is unavailable."
: "Use the current generated combat effects."}</small>
</span>
{optionButton(
"option-vfx-mode",
presentationSettings.vfxMode === "original" ? "Original" : "Generated",
cycleVfxMode,
!originalMediaReady,
)}
</div>
<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} />;
}