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) => void; onReset: () => void; presentationSettings: CombatPresentationSettings; onPresentationChange: (patch: Partial) => void; onPresentationReset: () => void; open?: boolean; onClose?: () => void; } interface ToggleOption { id: keyof Pick; 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>) { const [combatMediaAction, setCombatMediaAction] = useState({ 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(() => [ { 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) => ( {label} ); return (

Gameplay and interface

Options

KeybindsView and change every PC and Controller action. Resolve conflicts or restore movement. {optionButton("option-keybinds", "Keybinds", () => useGameStore.getState().openOverlay("keybinds"))}

Performance

Gameplay frame rateLimit 3D gameplay rendering without changing combat or physics timing. {optionButton( "option-gameplay-frame-rate", frameRateLimitLabel(settings.gameplayFrameRate), cycleGameplayFrameRate, )}
Character preview frame rateLimit animated character previews shown in menus. {optionButton( "option-preview-frame-rate", frameRateLimitLabel(settings.previewFrameRate), cyclePreviewFrameRate, )}

Gameplay and interface

{TOGGLES.map((option) => (
{option.label}{option.description} onChange({ [option.id]: !settings[option.id] })} >{settings[option.id] ? "On" : "Off"}
))}
Master volumeOverall game audio volume.
{optionButton("option-volume-down", "−", () => onChange({ masterVolume: quantize(settings.masterVolume - 0.1, 0.1, 0, 1) }), settings.masterVolume <= 0)} {Math.round(settings.masterVolume * 100)}% {optionButton("option-volume-up", "+", () => onChange({ masterVolume: quantize(settings.masterVolume + 0.1, 0.1, 0, 1) }), settings.masterVolume >= 1)}
Interface scaleResize HUD and menu elements for either Thor display or PC.
{optionButton("option-scale-down", "−", () => onChange({ uiScale: quantize(settings.uiScale - 0.05, 0.05, 0.75, 1.25) }), settings.uiScale <= 0.75)} {Math.round(settings.uiScale * 100)}% {optionButton("option-scale-up", "+", () => onChange({ uiScale: quantize(settings.uiScale + 0.05, 0.05, 0.75, 1.25) }), settings.uiScale >= 1.25)}
Minimap orientationKeep north fixed or rotate the map with your character. {optionButton("option-minimap", settings.minimapRotation === "north-up" ? "North up" : "Player up", () => onChange({ minimapRotation: settings.minimapRotation === "north-up" ? "player-up" : "north-up", }))}
Threat meter positionDrag its title bar during combat, or restore its suggested location. {optionButton("option-threat-position", "Reset position", () => onChange({ threatMeterPosition: DEFAULT_THREAT_METER_POSITION, }))}

Combat presentation

Original spell effects & sounds {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."} {combatMediaAction.kind === "error" ? {combatMediaAction.message} : null} {optionButton( "option-original-media", combatMediaButtonLabel, toggleOriginalMedia, combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing", )}
Spell visual mode {!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."} {optionButton( "option-vfx-mode", presentationSettings.vfxMode === "original" ? "Original" : "Generated", cycleVfxMode, !originalMediaReady, )}
VFX qualityHigh uses the full effect budget; Low reduces particles and simultaneous effects; Off skips combat VFX. {optionButton("option-vfx-quality", presentationSettings.vfxQuality[0].toUpperCase() + presentationSettings.vfxQuality.slice(1), cycleVfxQuality)}
{COMBAT_ACTOR_GROUPS.map((group) => (
{actorGroupLabel(group)} VFXShow attacks, spells, trails, impacts, and auras from this group. patchGroupBoolean("vfxEnabled", group, !presentationSettings.vfxEnabled[group])} >{presentationSettings.vfxEnabled[group] ? "On" : "Off"}
{actorGroupLabel(group)} combat audioMute or mix ability and weapon sounds from this group.
patchGroupBoolean("audioEnabled", group, !presentationSettings.audioEnabled[group])} >{presentationSettings.audioEnabled[group] ? "On" : "Muted"}
{optionButton(`option-audio-${group}-down`, "−", () => patchGroupVolume(group, presentationSettings.audioVolume[group] - 0.1), presentationSettings.audioVolume[group] <= 0)} {Math.round(presentationSettings.audioVolume[group] * 100)}% {optionButton(`option-audio-${group}-up`, "+", () => patchGroupVolume(group, presentationSettings.audioVolume[group] + 0.1), presentationSettings.audioVolume[group] >= 1)}
))}
{ onReset(); onPresentationReset(); }}>Restore defaults
A ChangeB Pause menu
); } 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 ; }