feat(ui): wire Display GL knobs + per-toon Character key — Settings goes live

Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.

DISPLAY ↔ GL window:

 · FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
   pushed every frame so dragging is visible immediately. Brainstorm
   said FOV is a live-preview slider; this delivers it.
 · VSync → _window.VSync, change-detected per-frame so flipping the
   checkbox is instant. Applied at startup too so saved-VSync takes
   effect before the first frame.
 · Resolution → _window.Size on Save (TryParseResolution parses
   "WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
   Save only.
 · Fullscreen → _window.WindowState (Silk.NET borderless mode), also
   on Save only.
 · ShowFps → wraps the title-bar perf string. true → full perf line;
   false → just "acdream" for a cleaner alt-tab. Default true matches
   pre-L.0 behavior.

Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.

ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.

CHARACTER ↔ active toon:

 · _activeToonKey field replaces the hard-coded "default" — starts as
   "default" (used for any pre-login Settings interaction), gets
   swapped to the actual character.Name immediately after EnterWorld
   in BeginLiveSessionAsync.
 · onSaveCharacter callback closes over _activeToonKey by reference
   (lambda captures `this`), so saves always write to the current
   toon's slot without rebinding the lambda.
 · After EnterWorld lands the chosen toon's name, the host loads
   that toon's bag via SettingsStore.LoadCharacter and calls a new
   SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
   AND draft atomically — HasUnsavedChanges stays false on login so
   the user doesn't see a "pending changes" indicator just because
   they switched toons.

Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".

2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 21:18:07 +02:00
parent 73749d176a
commit fc1e1933aa
5 changed files with 200 additions and 28 deletions

View file

@ -901,22 +901,34 @@ public sealed class GameWindow : IDisposable
// the same OnLoad path (see _inputDispatcher field). // the same OnLoad path (see _inputDispatcher field).
if (_inputDispatcher is not null) if (_inputDispatcher is not null)
{ {
// L.0 — settings.json (display + audio + future gameplay / // L.0 — settings.json (display + audio + gameplay + chat
// chat / character tabs). Coexists with keybinds.json, // + character). Coexists with keybinds.json, which
// which keeps its own load/save path. // keeps its own load/save path. Field-stored so the
var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( // post-EnterWorld branch can re-load the chosen
// toon's character bag.
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var settingsStore = _settingsStore;
var persistedDisplay = settingsStore.LoadDisplay(); var persistedDisplay = settingsStore.LoadDisplay();
var persistedAudio = settingsStore.LoadAudio(); var persistedAudio = settingsStore.LoadAudio();
var persistedGameplay = settingsStore.LoadGameplay(); var persistedGameplay = settingsStore.LoadGameplay();
var persistedChat = settingsStore.LoadChat(); var persistedChat = settingsStore.LoadChat();
// Per-toon character settings keyed by name. We don't // Character bag is loaded against _activeToonKey ("default"
// know which toon the user will pick until after // until BeginLiveSessionAsync swaps in the real name).
// CharacterList lands, so use a "default" bag for now. var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey);
// Future: swap to the actual toon name once a
// currentCharacter source is plumbed. // L.0 Display tab — apply persisted window-level
const string toonKey = "default"; // settings BEFORE the first frame so the user's saved
var persistedCharacter = settingsStore.LoadCharacter(toonKey); // VSync / Resolution / Fullscreen take effect from
// the start instead of flashing the WindowOptions
// defaults. FOV and ShowFps come through the
// per-frame push in OnRender so live preview works.
if (_window is not null)
{
if (_window.VSync != persistedDisplay.VSync)
_window.VSync = persistedDisplay.VSync;
ApplyDisplayWindowState(persistedDisplay);
}
// Apply persisted audio to the engine BEFORE the panel // Apply persisted audio to the engine BEFORE the panel
// host starts pushing per-frame so the first frame uses // host starts pushing per-frame so the first frame uses
@ -957,6 +969,12 @@ public sealed class GameWindow : IDisposable
Console.WriteLine( Console.WriteLine(
"settings: display saved to " "settings: display saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Apply window-level changes that are too
// jarring to live-preview (resolution +
// fullscreen). VSync / FOV / ShowFps
// already track DisplayDraft via the
// per-frame push.
ApplyDisplayWindowState(display);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -1020,9 +1038,14 @@ public sealed class GameWindow : IDisposable
{ {
try try
{ {
settingsStore.SaveCharacter(toonKey, character); // _activeToonKey is updated by
// BeginLiveSessionAsync after EnterWorld
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
settingsStore.SaveCharacter(_activeToonKey, character);
Console.WriteLine( Console.WriteLine(
$"settings: character[{toonKey}] saved to " $"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
} }
catch (Exception ex) catch (Exception ex)
@ -1501,6 +1524,20 @@ public sealed class GameWindow : IDisposable
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0); _liveSession.EnterWorld(user, characterIndex: 0);
// L.0 Character tab: swap the SettingsVM's character bag
// from the "default" pre-login bag to the actual chosen
// toon's bag. Every Save from now on writes under the
// chosen toon's name. LoadCharacterContext rebinds BOTH
// persisted + draft so HasUnsavedChanges doesn't flag the
// swap as a pending edit.
_activeToonKey = chosen.Name;
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
}
// Phase K.2: arm auto-entry. The guard's predicates won't // Phase K.2: arm auto-entry. The guard's predicates won't
// pass yet — the entity stream hasn't started — but the // pass yet — the entity stream hasn't started — but the
// OnUpdate tick re-checks every frame and fires once // OnUpdate tick re-checks every frame and fires once
@ -4209,6 +4246,25 @@ public sealed class GameWindow : IDisposable
_audioEngine.AmbientVolume = a.Ambient; _audioEngine.AmbientVolume = a.Ambient;
} }
// L.0 Display tab: push the live DisplayDraft into the
// active rendering surfaces each frame. FOV is the live-
// preview slider per the brainstorm — dragging it changes
// camera FovY immediately. VSync change-detected to avoid
// spamming the window. Resolution + Fullscreen apply on
// Save (handled by ApplyDisplayWindowState — too jarring
// to live-preview a resize).
if (_settingsVm is not null && _cameraController is not null)
{
var d = _settingsVm.DisplayDraft;
float fovYRad = d.FieldOfView * (MathF.PI / 180f);
_cameraController.Orbit.FovY = fovYRad;
_cameraController.Fly.FovY = fovYRad;
if (_cameraController.Chase is not null)
_cameraController.Chase.FovY = fovYRad;
if (_window is not null && _window.VSync != d.VSync)
_window.VSync = d.VSync;
}
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
// correctly relative to where we're looking. // correctly relative to where we're looking.
if (_audioEngine is not null && _audioEngine.IsAvailable) if (_audioEngine is not null && _audioEngine.IsAvailable)
@ -4492,9 +4548,16 @@ public sealed class GameWindow : IDisposable
int entityCount = _worldState.Entities.Count; int entityCount = _worldState.Entities.Count;
int animatedCount = _animatedEntities.Count; int animatedCount = _animatedEntities.Count;
_window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + // L.0 Display tab: ShowFps gates the perf string in the
$"lb {visibleLandblocks}/{totalLandblocks} visible | " + // title bar. Default is true (matches pre-L.0 behaviour);
$"ent {entityCount} | anim {animatedCount}"; // unchecking the toggle in Display tab collapses the title
// to just "acdream" for a cleaner alt-tab experience.
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
_window!.Title = showFps
? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
+ $"lb {visibleLandblocks}/{totalLandblocks} visible | "
+ $"ent {entityCount} | anim {animatedCount}"
: "acdream";
_lastFps = fps; _lastFps = fps;
_lastFrameMs = avgFrameTime; _lastFrameMs = avgFrameTime;
_perfAccum = 0; _perfAccum = 0;
@ -5337,6 +5400,55 @@ public sealed class GameWindow : IDisposable
// default; F11 / View → Settings toggles. Null when devtools are off. // default; F11 / View → Settings toggles. Null when devtools are off.
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so BeginLiveSessionAsync can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec)) return false;
var parts = spec.Split('x', 2);
if (parts.Length != 2) return false;
return int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
// Vitals panel reference cached for the View menu's toggle entry. // Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel; private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;

View file

@ -22,14 +22,17 @@ public sealed record DisplaySettings(
float Gamma, float Gamma,
bool ShowFps) bool ShowFps)
{ {
/// <summary>Values used on first launch / when settings.json is absent.</summary> /// <summary>Values used on first launch / when settings.json is absent.
/// FieldOfView (60°) and VSync (false) match the camera + window
/// defaults that shipped before L.0, so opening Display + Save
/// without touching anything is a visual no-op.</summary>
public static DisplaySettings Default { get; } = new( public static DisplaySettings Default { get; } = new(
Resolution: "1920x1080", Resolution: "1920x1080",
Fullscreen: false, Fullscreen: false,
VSync: true, VSync: false,
FieldOfView: 75f, FieldOfView: 60f,
Gamma: 1.0f, Gamma: 1.0f,
ShowFps: false); ShowFps: true);
/// <summary>16:9 resolution presets offered in the dropdown.</summary> /// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[] public static IReadOnlyList<string> AvailableResolutions { get; } = new[]

View file

@ -196,6 +196,21 @@ public sealed class SettingsVM
_characterDraft = value ?? throw new ArgumentNullException(nameof(value)); _characterDraft = value ?? throw new ArgumentNullException(nameof(value));
} }
/// <summary>
/// Replace BOTH the persisted snapshot and the live draft for the
/// Character bag. Used when the active toon changes (e.g. on
/// EnterWorld with a non-default character) — the host loads that
/// toon's settings from disk and pushes them into the VM here so
/// <see cref="HasUnsavedChanges"/> doesn't flag the swap as a
/// pending edit. Differs from <see cref="SetCharacter"/>, which
/// updates draft only.
/// </summary>
public void LoadCharacterContext(CharacterSettings persisted)
{
_characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_characterDraft = persisted;
}
/// <summary> /// <summary>
/// Begin rebinding <paramref name="action"/>. The supplied /// Begin rebinding <paramref name="action"/>. The supplied
/// <paramref name="original"/> binding will be removed when the new /// <paramref name="original"/> binding will be removed when the new

View file

@ -11,15 +11,19 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
public sealed class DisplaySettingsTests public sealed class DisplaySettingsTests
{ {
[Fact] [Fact]
public void Default_values_match_brainstorm_agreement() public void Default_values_match_pre_L0_runtime_state()
{ {
// Defaults pinned to match the camera FovY (60° = π/3) and the
// pre-L.0 window options (VSync off, FPS in title bar). Opening
// Display + Save without touching anything must NOT change the
// user's visual experience.
var d = DisplaySettings.Default; var d = DisplaySettings.Default;
Assert.Equal("1920x1080", d.Resolution); Assert.Equal("1920x1080", d.Resolution);
Assert.False(d.Fullscreen); Assert.False(d.Fullscreen);
Assert.True(d.VSync); Assert.False(d.VSync);
Assert.Equal(75f, d.FieldOfView); Assert.Equal(60f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma); Assert.Equal(1.0f, d.Gamma);
Assert.False(d.ShowFps); Assert.True(d.ShowFps);
} }
[Fact] [Fact]
@ -43,8 +47,8 @@ public sealed class DisplaySettingsTests
public void Equality_is_value_based() public void Equality_is_value_based()
{ {
var a = DisplaySettings.Default; var a = DisplaySettings.Default;
var b = DisplaySettings.Default with { ShowFps = true }; var b = DisplaySettings.Default with { Fullscreen = true };
var c = DisplaySettings.Default with { ShowFps = true }; var c = DisplaySettings.Default with { Fullscreen = true };
Assert.NotEqual(a, b); Assert.NotEqual(a, b);
Assert.Equal(b, c); Assert.Equal(b, c);
} }
@ -56,7 +60,8 @@ public sealed class DisplaySettingsTests
Assert.Equal(90f, d.FieldOfView); Assert.Equal(90f, d.FieldOfView);
// Other fields untouched. // Other fields untouched.
Assert.Equal("1920x1080", d.Resolution); Assert.Equal("1920x1080", d.Resolution);
Assert.True(d.VSync); Assert.False(d.VSync);
Assert.True(d.ShowFps);
} }
private static int ParseWidth(string res) private static int ParseWidth(string res)

View file

@ -271,7 +271,9 @@ public sealed class SettingsVMTests
public void SetDisplay_marks_unsaved_changes() public void SetDisplay_marks_unsaved_changes()
{ {
var (vm, _, _, _, _, _, _, _, _, _) = Build(); var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); // Default ShowFps is true → flip to false to ensure the with-
// expression actually mutates a field.
vm.SetDisplay(vm.DisplayDraft with { ShowFps = false });
Assert.True(vm.HasUnsavedChanges); Assert.True(vm.HasUnsavedChanges);
} }
@ -584,4 +586,39 @@ public sealed class SettingsVMTests
Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); Assert.Equal(CharacterSettings.Default, vm.CharacterDraft);
Assert.True(vm.HasUnsavedChanges); Assert.True(vm.HasUnsavedChanges);
} }
[Fact]
public void LoadCharacterContext_swaps_persisted_and_draft_atomically()
{
// Simulates the post-EnterWorld toon swap — host loads the
// chosen toon's bag from disk and pushes it via
// LoadCharacterContext. BOTH persisted and draft must update
// so HasUnsavedChanges stays false; otherwise the user would
// see a "pending changes" indicator on every login.
var (vm, _, _, _, _, _, _, _, _, _) = Build();
var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true };
vm.LoadCharacterContext(newToonBag);
Assert.Equal(newToonBag, vm.CharacterDraft);
Assert.False(vm.HasUnsavedChanges);
}
[Fact]
public void LoadCharacterContext_clears_pending_unsaved_character_edits()
{
// If the user had pending character edits from the previous
// toon (or pre-login session), swapping to a new toon's bag
// must wipe them — Save is per-toon, and bleed-through would
// write the pre-login bag's edits to the new toon's slot.
var (vm, _, _, _, _, _, _, _, _, _) = Build();
vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
Assert.True(vm.HasUnsavedChanges);
vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel);
Assert.False(vm.CharacterDraft.AutoAttack);
Assert.False(vm.HasUnsavedChanges);
}
} }