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); + } }