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:
parent
73749d176a
commit
fc1e1933aa
5 changed files with 200 additions and 28 deletions
|
|
@ -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";
|
||||
|
||||
/// <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.
|
||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,14 +22,17 @@ public sealed record DisplaySettings(
|
|||
float Gamma,
|
||||
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(
|
||||
Resolution: "1920x1080",
|
||||
Fullscreen: false,
|
||||
VSync: true,
|
||||
FieldOfView: 75f,
|
||||
VSync: false,
|
||||
FieldOfView: 60f,
|
||||
Gamma: 1.0f,
|
||||
ShowFps: false);
|
||||
ShowFps: true);
|
||||
|
||||
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
|
||||
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
|
||||
|
|
|
|||
|
|
@ -196,6 +196,21 @@ public sealed class SettingsVM
|
|||
_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>
|
||||
/// Begin rebinding <paramref name="action"/>. The supplied
|
||||
/// <paramref name="original"/> binding will be removed when the new
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue