acdream/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
Erik a37ebdebff fix(ui): pre-merge code review — apply persisted settings without devtools, hide inert sliders
Two should-fix items from the pre-merge code review pass:

1. Persisted settings now apply on startup unconditionally
   (previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
   underlying engine paths don't exist yet (R5 MIDI playback).

== 1. Settings load + apply outside DevToolsEnabled gate ==

Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.

Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.

The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.

== 2. Music + Ambient sliders hidden ==

OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.

Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.

dotnet build green; dotnet test 1,309 / 1,309 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:22:35 +02:00

502 lines
20 KiB
C#

using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// In-game Settings panel — F11 toggle (or View → Settings on the main
/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then
/// Display / Audio / Gameplay / Chat / Character (filling in over the
/// L.x sub-phases).
///
/// <para>
/// Top of the panel: Save / Cancel / Reset-all action buttons (global
/// across all tabs). When <see cref="SettingsVM.PendingConflict"/> is
/// non-null, a confirmation prompt is rendered above those buttons
/// (Yes — Reassign / No — Keep existing).
/// </para>
///
/// <para>
/// Below the action row a tab bar selects between the six categories.
/// Only the Keybinds tab is implemented today; the other five render
/// "Coming soon" placeholders so the structure the user approved in the
/// design brainstorm is visible immediately.
/// </para>
/// </summary>
public sealed class SettingsPanel : IPanel
{
private readonly SettingsVM _vm;
public SettingsPanel(SettingsVM vm)
{
_vm = vm ?? throw new System.ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.settings";
/// <inheritdoc />
public string Title => "Settings";
/// <inheritdoc />
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
public bool IsVisible { get; set; } = false;
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
if (!renderer.Begin(Title))
{
renderer.End();
return;
}
// Conflict prompt — modal-ish row at top of the panel.
if (_vm.PendingConflict is { } conflict)
{
renderer.TextWrapped(
$"'{ChordLabel(conflict.NewChord)}' is already bound to "
+ $"{conflict.ConflictingAction}. Reassign it to "
+ $"{conflict.NewAction}?");
if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true);
renderer.SameLine();
if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false);
renderer.Separator();
}
// Top action buttons. Global across all tabs.
if (renderer.Button("Save changes")) _vm.Save();
renderer.SameLine();
if (renderer.Button("Cancel changes")) _vm.Cancel();
renderer.SameLine();
if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults();
renderer.Separator();
if (renderer.BeginTabBar("settings.tabs"))
{
if (renderer.BeginTabItem("Keybinds"))
{
RenderKeybindsTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Display"))
{
RenderDisplayTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Audio"))
{
RenderAudioTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Gameplay"))
{
RenderGameplayTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Chat"))
{
RenderChatTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Character"))
{
RenderCharacterTab(renderer);
renderer.EndTabItem();
}
renderer.EndTabBar();
}
renderer.End();
}
/// <summary>
/// Render the Keybinds tab — eight collapsing-header sections matching
/// the retail keymap categories. Phase K shipped this content; the
/// only thing that changed is the wrapping tab item.
/// </summary>
private void RenderKeybindsTab(IPanelRenderer renderer)
{
RenderSection(renderer, "Movement", new[]
{
InputAction.MovementForward, InputAction.MovementBackup,
InputAction.MovementTurnLeft, InputAction.MovementTurnRight,
InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight,
InputAction.MovementJump, InputAction.MovementStop,
InputAction.MovementWalkMode, InputAction.MovementRunLock,
});
RenderSection(renderer, "Postures", new[]
{
InputAction.Ready, InputAction.Sitting,
InputAction.Crouch, InputAction.Sleeping,
});
RenderSection(renderer, "Camera", new[]
{
InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook,
InputAction.CameraRotateLeft, InputAction.CameraRotateRight,
InputAction.CameraRotateUp, InputAction.CameraRotateDown,
InputAction.CameraMoveToward, InputAction.CameraMoveAway,
InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson,
InputAction.CameraViewLookDown, InputAction.CameraViewMapMode,
});
RenderSection(renderer, "Combat", new[]
{
InputAction.CombatToggleCombat,
InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower,
InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack,
InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh,
InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab,
InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell,
});
RenderSection(renderer, "UI panels", new[]
{
InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel,
InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel,
InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel,
InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel,
InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel,
InputAction.SelectionExamine, InputAction.UseSelected,
InputAction.EscapeKey, InputAction.LOGOUT,
});
RenderSection(renderer, "Chat", new[]
{
InputAction.ToggleChatEntry, InputAction.EnterChatMode,
InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2,
InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4,
});
RenderSection(renderer, "Hotbar", new[]
{
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,
InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6,
InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9,
InputAction.CreateShortcut,
});
RenderSection(renderer, "Emotes", new[]
{
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
InputAction.Cheer, InputAction.PointState,
});
}
/// <summary>
/// Render the Display tab — resolution / fullscreen / vsync /
/// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders;
/// the others apply on Save (matches the brainstorm UX agreement —
/// resolution change live would be too jarring).
/// </summary>
private void RenderDisplayTab(IPanelRenderer renderer)
{
var d = _vm.DisplayDraft;
// Resolution dropdown. Index falls back to the highest available
// option when the persisted resolution isn't one of the presets
// (e.g. user hand-edited settings.json with a non-standard size).
var resolutions = DisplaySettings.AvailableResolutions.ToArray();
int idx = System.Array.IndexOf(resolutions, d.Resolution);
if (idx < 0) idx = resolutions.Length - 1;
if (renderer.Combo("Resolution", ref idx, resolutions))
_vm.SetDisplay(d with { Resolution = resolutions[idx] });
bool fullscreen = d.Fullscreen;
if (renderer.Checkbox("Fullscreen", ref fullscreen))
_vm.SetDisplay(d with { Fullscreen = fullscreen });
bool vsync = d.VSync;
if (renderer.Checkbox("V-Sync", ref vsync))
_vm.SetDisplay(d with { VSync = vsync });
float fov = d.FieldOfView;
if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f))
_vm.SetDisplay(d with { FieldOfView = fov });
float gamma = d.Gamma;
if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f))
_vm.SetDisplay(d with { Gamma = gamma });
bool showFps = d.ShowFps;
if (renderer.Checkbox("Show FPS", ref showFps))
_vm.SetDisplay(d with { ShowFps = showFps });
renderer.Spacing();
renderer.TextWrapped(
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ "preview live as you drag; Cancel reverts to the saved value.");
}
/// <summary>
/// Render the Audio tab — Master + SFX volume sliders (live preview
/// against the running OpenAL engine). Music + Ambient fields exist
/// in <see cref="AudioSettings"/> and persist round-trip, but their
/// sliders are intentionally hidden here because the underlying
/// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI
/// playback that hasn't shipped yet — exposing the sliders would be
/// "moving a knob that does nothing." When R5 lands, restore the
/// hidden sliders below and the JSON-persisted values will already
/// be in place.
/// </summary>
private void RenderAudioTab(IPanelRenderer renderer)
{
var a = _vm.AudioDraft;
float master = a.Master;
if (renderer.SliderFloat("Master", ref master, 0f, 1f))
_vm.SetAudio(a with { Master = master });
float sfx = a.Sfx;
if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
_vm.SetAudio(a with { Sfx = sfx });
// Music + Ambient hidden until R5 MIDI / ambient-loop engines
// exist. AudioSettings still carries the fields so the JSON
// round-trips and a future client doesn't drop them on save.
//
// float music = a.Music;
// if (renderer.SliderFloat("Music", ref music, 0f, 1f))
// _vm.SetAudio(a with { Music = music });
// float ambient = a.Ambient;
// if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
// _vm.SetAudio(a with { Ambient = ambient });
renderer.Spacing();
renderer.TextWrapped(
"Volume changes preview live as you drag. Save persists the "
+ "values to settings.json; Cancel reverts to the saved values. "
+ "Music + Ambient mixing arrives with R5 MIDI playback.");
}
/// <summary>
/// Render the Gameplay tab — ~14 toggles ported from retail's
/// CharacterOption + CharacterOptions2 bitfields. Local-only this
/// phase (no server sync). Grouped into Combat / Display / Interface
/// for first-run discoverability.
/// </summary>
private void RenderGameplayTab(IPanelRenderer renderer)
{
var g = _vm.GameplayDraft;
renderer.Text("Combat");
renderer.Separator();
bool autoTarget = g.AutoTarget;
if (renderer.Checkbox("Auto-target on attack", ref autoTarget))
_vm.SetGameplay(g with { AutoTarget = autoTarget });
bool autoRepeat = g.AutoRepeatAttack;
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
_vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat });
bool toggleRun = g.ToggleRun;
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
_vm.SetGameplay(g with { ToggleRun = toggleRun });
bool advCombat = g.AdvancedCombatUI;
if (renderer.Checkbox("Show advanced combat UI", ref advCombat))
_vm.SetGameplay(g with { AdvancedCombatUI = advCombat });
bool vivid = g.VividTargetingIndicator;
if (renderer.Checkbox("Vivid targeting indicator", ref vivid))
_vm.SetGameplay(g with { VividTargetingIndicator = vivid });
renderer.Spacing();
renderer.Text("Display");
renderer.Separator();
bool tooltips = g.ShowTooltips;
if (renderer.Checkbox("Show item tooltips", ref tooltips))
_vm.SetGameplay(g with { ShowTooltips = tooltips });
bool sideBySide = g.SideBySideVitals;
if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide))
_vm.SetGameplay(g with { SideBySideVitals = sideBySide });
bool coords = g.CoordinatesOnRadar;
if (renderer.Checkbox("Show coordinates on radar", ref coords))
_vm.SetGameplay(g with { CoordinatesOnRadar = coords });
bool spellDur = g.SpellDuration;
if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur))
_vm.SetGameplay(g with { SpellDuration = spellDur });
bool helm = g.ShowHelm;
if (renderer.Checkbox("Show helm on character", ref helm))
_vm.SetGameplay(g with { ShowHelm = helm });
bool cloak = g.ShowCloak;
if (renderer.Checkbox("Show cloak on character", ref cloak))
_vm.SetGameplay(g with { ShowCloak = cloak });
renderer.Spacing();
renderer.Text("Interface");
renderer.Separator();
bool allowGive = g.AllowGive;
if (renderer.Checkbox("Accept items handed by other players", ref allowGive))
_vm.SetGameplay(g with { AllowGive = allowGive });
bool lockUI = g.LockUI;
if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI))
_vm.SetGameplay(g with { LockUI = lockUI });
bool mouseTurn = g.UseMouseTurning;
if (renderer.Checkbox("Use mouse turning", ref mouseTurn))
_vm.SetGameplay(g with { UseMouseTurning = mouseTurn });
renderer.Spacing();
renderer.TextWrapped(
"Local-only this phase — values persist to settings.json but "
+ "don't yet sync to the server. Server sync arrives in a "
+ "follow-up phase.");
}
/// <summary>
/// Render the Chat tab — channel filters (Hear*Chat), display
/// preferences (timestamps / profanity filter / appear offline),
/// and a font-size slider. Channel filters affect client-side
/// display only this phase — the server still sends every line,
/// the client decides what to render.
/// </summary>
private void RenderChatTab(IPanelRenderer renderer)
{
var c = _vm.ChatDraft;
renderer.Text("Channel filters");
renderer.Separator();
bool general = c.HearGeneralChat;
if (renderer.Checkbox("General", ref general))
_vm.SetChat(c with { HearGeneralChat = general });
bool trade = c.HearTradeChat;
if (renderer.Checkbox("Trade", ref trade))
_vm.SetChat(c with { HearTradeChat = trade });
bool lfg = c.HearLFGChat;
if (renderer.Checkbox("LFG (looking for group)", ref lfg))
_vm.SetChat(c with { HearLFGChat = lfg });
bool rp = c.HearRoleplayChat;
if (renderer.Checkbox("Roleplay", ref rp))
_vm.SetChat(c with { HearRoleplayChat = rp });
bool society = c.HearSocietyChat;
if (renderer.Checkbox("Society (CD / EW / RB)", ref society))
_vm.SetChat(c with { HearSocietyChat = society });
renderer.Spacing();
renderer.Text("Display");
renderer.Separator();
bool timestamps = c.ShowTimestamps;
if (renderer.Checkbox("Show timestamps", ref timestamps))
_vm.SetChat(c with { ShowTimestamps = timestamps });
bool profanity = c.FilterProfanity;
if (renderer.Checkbox("Filter profanity", ref profanity))
_vm.SetChat(c with { FilterProfanity = profanity });
bool offline = c.AppearOffline;
if (renderer.Checkbox("Appear offline (hide from /who)", ref offline))
_vm.SetChat(c with { AppearOffline = offline });
float fontSize = c.FontSize;
if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f))
_vm.SetChat(c with { FontSize = fontSize });
renderer.Spacing();
renderer.TextWrapped(
"Channel filters hide messages from the chat window without "
+ "changing your server-side subscriptions. Save persists; "
+ "Cancel reverts.");
}
/// <summary>
/// Render the Character tab — per-toon preferences. The host owns
/// the toon-name key; the panel just edits whatever bag the host
/// loaded into <see cref="SettingsVM.CharacterDraft"/>.
/// </summary>
private void RenderCharacterTab(IPanelRenderer renderer)
{
var c = _vm.CharacterDraft;
var channels = CharacterSettings.AvailableChannels.ToArray();
int idx = System.Array.IndexOf(channels, c.DefaultChatChannel);
if (idx < 0) idx = 0;
if (renderer.Combo("Default chat channel", ref idx, channels))
_vm.SetCharacter(c with { DefaultChatChannel = channels[idx] });
bool autoAttack = c.AutoAttack;
if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack))
_vm.SetCharacter(c with { AutoAttack = autoAttack });
bool confirmSalvage = c.ConfirmSalvage;
if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage))
_vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage });
bool pickup = c.ShowPickupMessages;
if (renderer.Checkbox("Show pickup messages in chat", ref pickup))
_vm.SetCharacter(c with { ShowPickupMessages = pickup });
renderer.Spacing();
renderer.TextWrapped(
"Per-character preferences — saved per toon under "
+ "settings.json's character[\"<toonName>\"]. Local-only this "
+ "phase; server-sync arrives later when the protocol "
+ "round-trip lands.");
}
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.
bool defaultOpen = label == "Movement";
if (!renderer.CollapsingHeader(label, defaultOpen))
return;
foreach (var action in actions)
{
renderer.Text(action.ToString());
renderer.SameLine();
// Current binding(s) summary.
var binds = _vm.Draft.ForAction(action).ToList();
string summary = binds.Count == 0
? "(unbound)"
: string.Join(", ", binds.Select(b => ChordLabel(b.Chord)));
renderer.Text(summary);
renderer.SameLine();
// Rebind button — when a rebind is in progress for THIS
// action, the label changes to a "press a key..." prompt.
// The "##{action}" suffix gives ImGui a stable per-row id
// so multiple "Rebind" buttons don't collide.
string buttonLabel = (_vm.RebindInProgress == action)
? $"Press a key... (Esc to cancel)##{action}"
: $"Rebind##{action}";
if (renderer.Button(buttonLabel))
{
if (binds.Count > 0 && _vm.RebindInProgress is null)
_vm.BeginRebind(action, binds[0]);
}
renderer.SameLine();
if (renderer.Button($"Reset##{action}"))
_vm.ResetActionToDefault(action);
}
}
/// <summary>
/// Render a chord as <c>"Shift+Ctrl+A"</c> / <c>"W"</c> / etc. for the
/// row summary + conflict prompt. Joins held modifiers with <c>+</c>
/// then the trigger key name.
/// </summary>
private static string ChordLabel(KeyChord chord)
{
var parts = new List<string>();
if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift");
if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl");
if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt");
if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win");
parts.Add(chord.Key.ToString());
return string.Join("+", parts);
}
}