Phase L.0 (cont.) — first concrete tab on the new Settings shell, in the Easy-wins build order agreed in the brainstorm (Display → Audio → Gameplay → Chat → Character). DisplaySettings (immutable record): Resolution / Fullscreen / VSync / FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9 resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on, 75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement. SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json (coexists with keybinds.json — own load/save path stays put, no migration needed). LoadDisplay falls back per-field when keys are missing (partial-file tolerant) and falls back to defaults when the file is corrupt or the JSON is unparseable. SaveDisplay round-trips preserved — unknown top-level keys (e.g. an `audio` section written by a future client) are kept on save so older builds don't silently drop newer-tab data. SettingsVM gains a parallel display-state machine: persistedDisplay + draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both keybinds and display deltas, Save/Cancel/ResetAll cover both atomically from the user's POV (one Save commits everything, one Cancel reverts everything). Constructor signature extends with two new params; existing keybinds-only callers updated. SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder — Combo for resolution, Checkboxes for fullscreen/vsync/show-fps, SliderFloat for FOV + gamma. Live-preview note in the panel body matches the agreed UX: FOV + gamma update visibly while the user drags; resolution / fullscreen / vsync apply on Save (live preview would be too jarring). GameWindow wires SettingsStore into the existing SettingsVM construct site — load on startup, save on each tab Save. Errors print to console and don't crash the panel. 19 new tests: · DisplaySettings record (4) — defaults pinned, value equality, with- expressions, AvailableResolutions sorted ascending · SettingsStore (6) — round trip, missing-file → defaults, corrupt- file → defaults, partial-file → per-field fallback, unknown-key preservation, DefaultPath shape · SettingsVM display (6) — initial draft tracks persisted, SetDisplay marks dirty, Save invokes display callback, Cancel reverts, ResetAllToDefaults covers display, Save-then-Cancel is no-op · SettingsPanel display tab (3) — widgets render only when active, resolution combo uses AvailableResolutions, no Combo emitted on inactive tabs dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green (243 Core.Net + 330 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
295 lines
12 KiB
C#
295 lines
12 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"))
|
|
{
|
|
RenderPlaceholder(renderer, "Audio");
|
|
renderer.EndTabItem();
|
|
}
|
|
if (renderer.BeginTabItem("Gameplay"))
|
|
{
|
|
RenderPlaceholder(renderer, "Gameplay");
|
|
renderer.EndTabItem();
|
|
}
|
|
if (renderer.BeginTabItem("Chat"))
|
|
{
|
|
RenderPlaceholder(renderer, "Chat");
|
|
renderer.EndTabItem();
|
|
}
|
|
if (renderer.BeginTabItem("Character"))
|
|
{
|
|
RenderPlaceholder(renderer, "Character");
|
|
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>
|
|
/// Placeholder content shown for tabs whose implementation is still
|
|
/// pending. Reads as "Coming soon" plus a note about which sub-phase
|
|
/// is expected to fill it in.
|
|
/// </summary>
|
|
private static void RenderPlaceholder(IPanelRenderer renderer, string tabName)
|
|
{
|
|
renderer.TextWrapped($"{tabName} settings coming soon.");
|
|
renderer.Spacing();
|
|
renderer.TextWrapped(
|
|
"This tab is part of the staged Settings interface rollout. "
|
|
+ "Build order: Display → Audio → Gameplay → Chat → Character.");
|
|
}
|
|
|
|
/// <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.");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|