From fc1e1933aa852e520fb83c73db9f8ea0620b44a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 21:18:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20wire=20Display=20GL=20knobs=20+=20p?= =?UTF-8?q?er-toon=20Character=20key=20=E2=80=94=20Settings=20goes=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 144 ++++++++++++++++-- .../Panels/Settings/DisplaySettings.cs | 11 +- .../Panels/Settings/SettingsVM.cs | 15 ++ .../Panels/Settings/DisplaySettingsTests.cs | 19 ++- .../Panels/Settings/SettingsVMTests.cs | 39 ++++- 5 files changed, 200 insertions(+), 28 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6e102a5..e6b4f88 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -901,22 +901,34 @@ public sealed class GameWindow : IDisposable // the same OnLoad path (see _inputDispatcher field). if (_inputDispatcher is not null) { - // L.0 — settings.json (display + audio + future gameplay / - // chat / character tabs). Coexists with keybinds.json, - // which keeps its own load/save path. - var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + // L.0 — settings.json (display + audio + gameplay + chat + // + character). Coexists with keybinds.json, which + // keeps its own load/save path. Field-stored so the + // 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()); + var settingsStore = _settingsStore; var persistedDisplay = settingsStore.LoadDisplay(); var persistedAudio = settingsStore.LoadAudio(); var persistedGameplay = settingsStore.LoadGameplay(); var persistedChat = settingsStore.LoadChat(); - // Per-toon character settings keyed by name. We don't - // know which toon the user will pick until after - // CharacterList lands, so use a "default" bag for now. - // Future: swap to the actual toon name once a - // currentCharacter source is plumbed. - const string toonKey = "default"; - var persistedCharacter = settingsStore.LoadCharacter(toonKey); + // Character bag is loaded against _activeToonKey ("default" + // until BeginLiveSessionAsync swaps in the real name). + var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey); + + // L.0 Display tab — apply persisted window-level + // settings BEFORE the first frame so the user's saved + // 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 // host starts pushing per-frame so the first frame uses @@ -957,6 +969,12 @@ public sealed class GameWindow : IDisposable Console.WriteLine( "settings: display saved to " + 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) { @@ -1020,9 +1038,14 @@ public sealed class GameWindow : IDisposable { 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( - $"settings: character[{toonKey}] saved to " + $"settings: character[{_activeToonKey}] saved to " + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); } catch (Exception ex) @@ -1501,6 +1524,20 @@ public sealed class GameWindow : IDisposable _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _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 // pass yet — the entity stream hasn't started — but the // OnUpdate tick re-checks every frame and fires once @@ -4209,6 +4246,25 @@ public sealed class GameWindow : IDisposable _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 // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) @@ -4492,9 +4548,16 @@ public sealed class GameWindow : IDisposable int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; - _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + - $"lb {visibleLandblocks}/{totalLandblocks} visible | " + - $"ent {entityCount} | anim {animatedCount}"; + // L.0 Display tab: ShowFps gates the perf string in the + // title bar. Default is true (matches pre-L.0 behaviour); + // 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; _lastFrameMs = avgFrameTime; _perfAccum = 0; @@ -5337,6 +5400,55 @@ public sealed class GameWindow : IDisposable // default; F11 / View → Settings toggles. Null when devtools are off. private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel; 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"; + + /// + /// L.0 Display tab: apply the window-state-dependent settings + /// (Resolution + Fullscreen) from a + /// to the live Silk.NET window. Called at startup (with persisted + /// values) and on every Save (with the saved values). Resolution + /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed + /// or unparseable string is silently ignored to avoid crashing the + /// client mid-session. + /// + 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(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. private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel; diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index dd89b6c..505a2d3 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -22,14 +22,17 @@ public sealed record DisplaySettings( float Gamma, bool ShowFps) { - /// Values used on first launch / when settings.json is absent. + /// 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. public static DisplaySettings Default { get; } = new( Resolution: "1920x1080", Fullscreen: false, - VSync: true, - FieldOfView: 75f, + VSync: false, + FieldOfView: 60f, Gamma: 1.0f, - ShowFps: false); + ShowFps: true); /// 16:9 resolution presets offered in the dropdown. public static IReadOnlyList AvailableResolutions { get; } = new[] diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index c32fade..f4bcc36 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -196,6 +196,21 @@ public sealed class SettingsVM _characterDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// 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 + /// doesn't flag the swap as a + /// pending edit. Differs from , which + /// updates draft only. + /// + public void LoadCharacterContext(CharacterSettings persisted) + { + _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); + _characterDraft = persisted; + } + /// /// Begin rebinding . The supplied /// binding will be removed when the new diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs index f73db09..9e4e13d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs @@ -11,15 +11,19 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; public sealed class DisplaySettingsTests { [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; Assert.Equal("1920x1080", d.Resolution); Assert.False(d.Fullscreen); - Assert.True(d.VSync); - Assert.Equal(75f, d.FieldOfView); + Assert.False(d.VSync); + Assert.Equal(60f, d.FieldOfView); Assert.Equal(1.0f, d.Gamma); - Assert.False(d.ShowFps); + Assert.True(d.ShowFps); } [Fact] @@ -43,8 +47,8 @@ public sealed class DisplaySettingsTests public void Equality_is_value_based() { var a = DisplaySettings.Default; - var b = DisplaySettings.Default with { ShowFps = true }; - var c = DisplaySettings.Default with { ShowFps = true }; + var b = DisplaySettings.Default with { Fullscreen = true }; + var c = DisplaySettings.Default with { Fullscreen = true }; Assert.NotEqual(a, b); Assert.Equal(b, c); } @@ -56,7 +60,8 @@ public sealed class DisplaySettingsTests Assert.Equal(90f, d.FieldOfView); // Other fields untouched. Assert.Equal("1920x1080", d.Resolution); - Assert.True(d.VSync); + Assert.False(d.VSync); + Assert.True(d.ShowFps); } private static int ParseWidth(string res) diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 1bedd6c..b892cf1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -271,7 +271,9 @@ public sealed class SettingsVMTests public void SetDisplay_marks_unsaved_changes() { 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); } @@ -584,4 +586,39 @@ public sealed class SettingsVMTests Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); 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); + } }