Merge branch 'feature/settings-retail' — Phase L.0 Settings interface
Lands the full retail-style Settings interface developed in the .worktrees/settings-retail worktree. 17 commits delivering: == Phase L.0 — Settings interface ==7665cdftabbed Settings shell + IPanelRenderer tab API extension382f0adDisplay tab + settings.json persistence layer53b1878Audio tab + live volume sliders driving OpenAL engineb7165e5Gameplay tab — 14 retail CharacterOption-derived toggles356b5f2Chat tab — channel filters + display prefs + font slider73749d1Character tab — per-toon settings; Phase L.0 completefc1e193wire Display GL knobs + per-toon Character key4c75cedchat Copy mode — read-only multi-line for select + Ctrl+C == Drag-fix iteration ==6273255first attempt at title-bar-only drag (Begin-level absorber)2818fccscope drag absorber to BeginChild (fixed Settings tabs)df9f2fdwrap chat panel body in outer BeginChild (fixed chat drag) == Pre-merge code review fixes ==944a036rescue commit — orphaned FramebufferResize + ResetPanelLayout (working-tree changes that never got committed in the cwd shenanigans during earlier iteration)a37ebdeapply persisted Display + Audio settings without devtools gate (settings are runtime state, not devtools state); hide Music + Ambient sliders that were inert (R5 MIDI not shipped)23aa017docs/plans/roadmap shipped table updated for K + L.0 == Net delivered == · 6-tab F11 Settings panel: Keybinds (existing) + Display + Audio + Gameplay + Chat + Character · settings.json at %LOCALAPPDATA%\acdream\ — five sections coexist non-destructively, per-toon Character keying · Display: Resolution / Fullscreen / VSync / FOV / ShowFps live-wired to Silk.NET window + camera FovY + title-bar perf string · Audio: Master + SFX volume live-driving OpenAL engine · Gameplay/Chat/Character: persist for forthcoming server-sync wiring · Chat panel Copy mode (Ctrl+C selectable text) · Title-bar-only window drag (BeginChild absorber) · FramebufferResize handler — GL viewport + camera aspect + panel layout stay in sync on window resize · "Reset window layout" View menu item · IPanelRenderer extensions: tab API + TextMultilineReadOnly dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green (243 Core.Net + 393 UI.Abstractions + 673 Core; +87 net new tests since fork). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
a2e0bb5e2f
23 changed files with 3101 additions and 60 deletions
|
|
@ -595,6 +595,12 @@ public sealed class GameWindow : IDisposable
|
|||
_window.Update += OnUpdate;
|
||||
_window.Render += OnRender;
|
||||
_window.Closing += OnClosing;
|
||||
// L.0 Display tab: keep the GL viewport + camera aspect in sync
|
||||
// with the window framebuffer. Without this handler, resizing
|
||||
// the window (or applying a Display-tab Resolution change at
|
||||
// startup) leaves the viewport pinned to the original size —
|
||||
// user sees a small render in the corner of a big window.
|
||||
_window.FramebufferResize += OnFramebufferResize;
|
||||
|
||||
_window.Run();
|
||||
}
|
||||
|
|
@ -833,6 +839,17 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// L.0 follow-up — load + apply persisted Display / Audio settings
|
||||
// BEFORE the DevToolsEnabled block. The settings.json values
|
||||
// (resolution, vsync, FOV, master volume, etc) are runtime
|
||||
// settings, not devtools settings — a user running without
|
||||
// ACDREAM_DEVTOOLS=1 still expects their saved values to take
|
||||
// effect. The Settings PANEL (editing UI) is gated on devtools;
|
||||
// the persisted state is not. Caches values into fields so the
|
||||
// SettingsVM construction in the devtools block reads them
|
||||
// without re-loading.
|
||||
LoadAndApplyPersistedSettings();
|
||||
|
||||
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
|
||||
// isn't set: no context creation, no per-frame branches hit.
|
||||
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
|
||||
|
|
@ -922,8 +939,15 @@ public sealed class GameWindow : IDisposable
|
|||
// the draft. Construction is null-safe vs. the
|
||||
// dispatcher because the dispatcher is built earlier in
|
||||
// the same OnLoad path (see _inputDispatcher field).
|
||||
if (_inputDispatcher is not null)
|
||||
if (_inputDispatcher is not null && _settingsStore is not null)
|
||||
{
|
||||
// L.0 — SettingsStore + persisted-settings load + apply
|
||||
// happened earlier in OnLoad via
|
||||
// LoadAndApplyPersistedSettings (settings are runtime
|
||||
// state, not devtools state — they take effect even
|
||||
// when ACDREAM_DEVTOOLS=0). Here we just construct the
|
||||
// Settings PANEL on top of the already-loaded values.
|
||||
var settingsStore = _settingsStore;
|
||||
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
|
||||
persisted: _keyBindings,
|
||||
dispatcher: _inputDispatcher,
|
||||
|
|
@ -942,12 +966,113 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
Console.WriteLine($"keybinds: save failed: {ex.Message}");
|
||||
}
|
||||
},
|
||||
persistedDisplay: _persistedDisplay,
|
||||
onSaveDisplay: display =>
|
||||
{
|
||||
try
|
||||
{
|
||||
settingsStore.SaveDisplay(display);
|
||||
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)
|
||||
{
|
||||
Console.WriteLine($"settings: display save failed: {ex.Message}");
|
||||
}
|
||||
},
|
||||
persistedAudio: _persistedAudio,
|
||||
onSaveAudio: audio =>
|
||||
{
|
||||
try
|
||||
{
|
||||
settingsStore.SaveAudio(audio);
|
||||
Console.WriteLine(
|
||||
"settings: audio saved to "
|
||||
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: audio save failed: {ex.Message}");
|
||||
}
|
||||
},
|
||||
persistedGameplay: _persistedGameplay,
|
||||
onSaveGameplay: gameplay =>
|
||||
{
|
||||
try
|
||||
{
|
||||
settingsStore.SaveGameplay(gameplay);
|
||||
Console.WriteLine(
|
||||
"settings: gameplay saved to "
|
||||
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
// Local-only this phase. Server-sync packet
|
||||
// (CharacterOption bitmask) goes in here when
|
||||
// the protocol round-trip is in place.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: gameplay save failed: {ex.Message}");
|
||||
}
|
||||
},
|
||||
persistedChat: _persistedChat,
|
||||
onSaveChat: chat =>
|
||||
{
|
||||
try
|
||||
{
|
||||
settingsStore.SaveChat(chat);
|
||||
Console.WriteLine(
|
||||
"settings: chat saved to "
|
||||
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
// Channel filters affect client-side display
|
||||
// only this phase. ChatPanel will read them
|
||||
// off SettingsVM.ChatDraft when filtering is
|
||||
// wired into the chat-line render path.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: chat save failed: {ex.Message}");
|
||||
}
|
||||
},
|
||||
persistedCharacter: _persistedCharacter,
|
||||
onSaveCharacter: character =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// _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[{_activeToonKey}] saved to "
|
||||
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: character save failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
_settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
|
||||
_panelHost.Register(_settingsPanel);
|
||||
}
|
||||
|
||||
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
|
||||
|
||||
// L.0 Display tab: seed sensible default positions for
|
||||
// every registered panel. cond=FirstUseEver means imgui.ini
|
||||
// takes precedence on subsequent launches — the user's
|
||||
// dragged positions persist. Without this, the first-run
|
||||
// experience stacks every panel at (0,0) which looks
|
||||
// broken.
|
||||
ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -1464,6 +1589,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
|
||||
|
|
@ -4260,6 +4399,41 @@ public sealed class GameWindow : IDisposable
|
|||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
||||
|
||||
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the
|
||||
// engine each frame, so volume sliders preview audibly while
|
||||
// the user drags. Cancel reverts the draft and the engine
|
||||
// catches up on the very next frame; Save persists to
|
||||
// settings.json without changing engine state (already
|
||||
// applied). Cheap enough to run unconditionally on every
|
||||
// tick — four float assignments.
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null)
|
||||
{
|
||||
var a = _settingsVm.AudioDraft;
|
||||
_audioEngine.MasterVolume = a.Master;
|
||||
_audioEngine.MusicVolume = a.Music;
|
||||
_audioEngine.SfxVolume = a.Sfx;
|
||||
_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)
|
||||
|
|
@ -4510,6 +4684,15 @@ public sealed class GameWindow : IDisposable
|
|||
if (_debugPanel is not null
|
||||
&& ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1"))
|
||||
_debugPanel.IsVisible = !_debugPanel.IsVisible;
|
||||
ImGuiNET.ImGui.Separator();
|
||||
// L.0 Display tab: a manual reset for users whose
|
||||
// imgui.ini has saved a panel position that's now
|
||||
// off-screen (after a window shrink, monitor swap,
|
||||
// or a malformed save). Force-resets every panel
|
||||
// to its default landing position. The same code
|
||||
// path runs automatically on FramebufferResize.
|
||||
if (ImGuiNET.ImGui.MenuItem("Reset window layout"))
|
||||
ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
|
||||
ImGuiNET.ImGui.EndMenu();
|
||||
}
|
||||
// K-fix2 (2026-04-26): Camera submenu — discoverable
|
||||
|
|
@ -4543,9 +4726,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;
|
||||
|
|
@ -5478,6 +5668,188 @@ 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 follow-up: persisted-settings cache populated by
|
||||
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
|
||||
// not gated on DevToolsEnabled). The Settings PANEL construction
|
||||
// — which IS gated on devtools — reads these fields when wiring
|
||||
// SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings
|
||||
// overwrites them with values from settings.json (or per-section
|
||||
// defaults when the file is missing/corrupt).
|
||||
private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay
|
||||
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
|
||||
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
|
||||
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
|
||||
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
|
||||
= AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default;
|
||||
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
|
||||
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
|
||||
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
|
||||
= AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default;
|
||||
|
||||
/// <summary>
|
||||
/// L.0 follow-up: load every section from settings.json + apply the
|
||||
/// runtime-affecting ones (Display window state + Audio engine
|
||||
/// volumes) at startup. Runs unconditionally — settings are runtime
|
||||
/// state, not devtools state. Without this, a user running with
|
||||
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
|
||||
/// defaults instead of their saved Display/Audio preferences.
|
||||
/// </summary>
|
||||
private void LoadAndApplyPersistedSettings()
|
||||
{
|
||||
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
|
||||
_persistedDisplay = _settingsStore.LoadDisplay();
|
||||
_persistedAudio = _settingsStore.LoadAudio();
|
||||
_persistedGameplay = _settingsStore.LoadGameplay();
|
||||
_persistedChat = _settingsStore.LoadChat();
|
||||
// _activeToonKey is "default" pre-EnterWorld; the post-login
|
||||
// branch in BeginLiveSessionAsync swaps to the chosen toon's
|
||||
// name and re-loads via SettingsVM.LoadCharacterContext.
|
||||
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
|
||||
|
||||
// Apply Display to the Silk.NET window. VSync goes via the
|
||||
// window property; resolution + fullscreen go through
|
||||
// ApplyDisplayWindowState which is shared with the on-Save path.
|
||||
if (_window is not null)
|
||||
{
|
||||
if (_window.VSync != _persistedDisplay.VSync)
|
||||
_window.VSync = _persistedDisplay.VSync;
|
||||
ApplyDisplayWindowState(_persistedDisplay);
|
||||
}
|
||||
|
||||
// Apply Audio to the OpenAL engine. Master + Sfx are wired
|
||||
// through to the engine; Music + Ambient are stored but inert
|
||||
// until R5 MIDI/ambient-loop engines exist (assigning them is
|
||||
// harmless — the engine just doesn't read them yet).
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||
{
|
||||
_audioEngine.MasterVolume = _persistedAudio.Master;
|
||||
_audioEngine.MusicVolume = _persistedAudio.Music;
|
||||
_audioEngine.SfxVolume = _persistedAudio.Sfx;
|
||||
_audioEngine.AmbientVolume = _persistedAudio.Ambient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
|
||||
/// + camera aspect when the window is resized (by the user dragging
|
||||
/// the corner OR by ApplyDisplayWindowState applying a saved
|
||||
/// Resolution). Without this, the viewport stays pinned at the
|
||||
/// startup size, producing a small render inside a big window.
|
||||
/// Also force-resets ImGui panel layout so panels that were
|
||||
/// previously off the new viewport snap back to default positions.
|
||||
/// </summary>
|
||||
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
|
||||
{
|
||||
if (newSize.X <= 0 || newSize.Y <= 0) return;
|
||||
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
|
||||
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
|
||||
// Resize is always a force-reset — the alternative ("clamp
|
||||
// existing positions") would require tracking each panel's
|
||||
// current pos+size, which ImGuiNET doesn't expose by name.
|
||||
// Force-reset is acceptable UX because resizing happens rarely
|
||||
// and the user can always drag panels back where they want.
|
||||
if (DevToolsEnabled && _imguiBootstrap is not null)
|
||||
ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L.0 Display tab: position every registered panel to its default
|
||||
/// landing spot, computed relative to the current window size so
|
||||
/// the layout adapts to any resolution. Called from:
|
||||
/// <list type="bullet">
|
||||
/// <item>OnFramebufferResize (cond=Always — force-reset on resize).</item>
|
||||
/// <item>The View → "Reset window layout" menu item (cond=Always).</item>
|
||||
/// <item>OnLoad after panel registration (cond=FirstUseEver — only
|
||||
/// applies when imgui.ini has no saved position for that
|
||||
/// panel; on subsequent launches the saved positions win).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private void ResetPanelLayout(ImGuiNET.ImGuiCond cond)
|
||||
{
|
||||
if (_window is null) return;
|
||||
float w = _window.Size.X;
|
||||
float h = _window.Size.Y;
|
||||
// Sane minimums so the math doesn't blow up on a tiny window.
|
||||
if (w < 480) w = 480;
|
||||
if (h < 320) h = 320;
|
||||
|
||||
// Panel positions chosen to be classic-MMO discoverable on a
|
||||
// 1280x720 window: vitals top-left under the menu bar, chat
|
||||
// bottom-left, debug top-right, settings centered. All sizes
|
||||
// are reasonable defaults the user can resize from.
|
||||
SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f),
|
||||
new System.Numerics.Vector2(220f, 110f), cond);
|
||||
SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f),
|
||||
new System.Numerics.Vector2(450f, 300f), cond);
|
||||
SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f),
|
||||
new System.Numerics.Vector2(370f, 520f), cond);
|
||||
SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f),
|
||||
new System.Numerics.Vector2(700f, 500f), cond);
|
||||
}
|
||||
|
||||
private static void SetPanelLayout(
|
||||
string? title,
|
||||
System.Numerics.Vector2 pos,
|
||||
System.Numerics.Vector2 size,
|
||||
ImGuiNET.ImGuiCond cond)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title)) return;
|
||||
// SetWindowPos/SetWindowSize by name work even when the window
|
||||
// has never been Begin'd — ImGui stores the value for next
|
||||
// appearance.
|
||||
ImGuiNET.ImGui.SetWindowPos(title, pos, cond);
|
||||
ImGuiNET.ImGui.SetWindowSize(title, size, cond);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
|
|
|
|||
|
|
@ -235,4 +235,48 @@ public interface IPanelRenderer
|
|||
/// frame the user clicks the item; false otherwise.
|
||||
/// </summary>
|
||||
bool MenuItem(string label, string? shortcut = null);
|
||||
|
||||
// -- Tab bar (Settings panel + future tabbed surfaces) ---------------
|
||||
|
||||
/// <summary>
|
||||
/// Open a tab bar inside the current window. Returns <c>true</c>
|
||||
/// when the bar is visible — only emit <see cref="BeginTabItem"/>
|
||||
/// calls inside that branch. Always pair with
|
||||
/// <see cref="EndTabBar"/> when the call returned true. Retail had
|
||||
/// tab bars in the Options UIs (<c>gmGameplayOptionsUI</c> etc), so
|
||||
/// this primitive must be expressible by the future custom
|
||||
/// retail-look backend.
|
||||
/// </summary>
|
||||
bool BeginTabBar(string id);
|
||||
|
||||
/// <summary>Close the tab bar opened by <see cref="BeginTabBar"/>.</summary>
|
||||
void EndTabBar();
|
||||
|
||||
/// <summary>
|
||||
/// Begin a single tab inside an open <see cref="BeginTabBar"/>.
|
||||
/// Returns <c>true</c> when the tab is the currently selected one
|
||||
/// — only render this tab's content in that branch. Always pair
|
||||
/// with <see cref="EndTabItem"/> when the call returned true.
|
||||
/// </summary>
|
||||
bool BeginTabItem(string label);
|
||||
|
||||
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
|
||||
void EndTabItem();
|
||||
|
||||
/// <summary>
|
||||
/// Render a read-only multi-line text region the user can
|
||||
/// <b>select</b> with click+drag and copy with <c>Ctrl+C</c>.
|
||||
/// Matches the typical "click into a textbox to grab text" UX —
|
||||
/// chat panels, log viewers, etc. use this to make text
|
||||
/// extractable without the user having to alt-tab + retype.
|
||||
///
|
||||
/// <para>
|
||||
/// The widget is sized to <paramref name="size"/>; pass
|
||||
/// <c>(0, 0)</c> for "fill the current content region" semantics
|
||||
/// (matches ImGui defaults). <paramref name="id"/> is the ImGui
|
||||
/// stable identifier — typically <c>"##chatcopy"</c> or similar
|
||||
/// hidden-label form.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
void TextMultilineReadOnly(string id, string content, Vector2 size);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
|
|
@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel
|
|||
// click into another widget.
|
||||
private bool _focusRequested;
|
||||
|
||||
// L.0 follow-up: "Copy mode" — when true, render the chat tail as
|
||||
// a read-only multi-line text widget the user can click+drag to
|
||||
// select + Ctrl+C to copy. Trades per-line color for selectability;
|
||||
// user toggles when they want to grab specific text out of the
|
||||
// log (item names, coordinates, NPC dialogue, etc).
|
||||
private bool _copyMode;
|
||||
|
||||
public ChatPanel(ChatVM vm)
|
||||
{
|
||||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||||
|
|
@ -82,6 +90,31 @@ public sealed class ChatPanel : IPanel
|
|||
return;
|
||||
}
|
||||
|
||||
// L.0 follow-up: wrap the entire chat panel body in a single
|
||||
// outer BeginChild so empty-space clicks anywhere in the body
|
||||
// (Checkbox row, between Separator and input, etc.) are
|
||||
// absorbed by BeginChild's drag-trap (an InvisibleButton the
|
||||
// ImGui renderer adds inside every BeginChild). Without this
|
||||
// wrapper the chat panel was draggable from any empty body
|
||||
// pixel — only the inner ##chattail area was protected.
|
||||
if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f)))
|
||||
{
|
||||
renderer.EndChild();
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
||||
// L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the
|
||||
// chat tail rendering swaps to TextMultilineReadOnly so the
|
||||
// user can mark + Ctrl+C any text. Off (default) preserves the
|
||||
// colored per-line render with combat highlights. The checkbox
|
||||
// sits ABOVE the chat tail (not in the footer) so it's always
|
||||
// visible regardless of scroll position.
|
||||
bool copyMode = _copyMode;
|
||||
if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode))
|
||||
_copyMode = copyMode;
|
||||
renderer.Separator();
|
||||
|
||||
// Phase J Tier 3: keep the input field at the bottom of the
|
||||
// window across resizes by reserving footer space and putting
|
||||
// the chat tail in a scrollable child that fills the rest.
|
||||
|
|
@ -95,7 +128,21 @@ public sealed class ChatPanel : IPanel
|
|||
// the plain Text path (visually identical to the I.4 panel).
|
||||
var lines = _vm.RecentLinesDetailed();
|
||||
|
||||
if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
|
||||
if (_copyMode)
|
||||
{
|
||||
// Copy mode: one big read-only multiline text widget
|
||||
// holding every visible line, joined with newlines. Loses
|
||||
// per-line color but lets the user click+drag to select
|
||||
// arbitrary spans of text + Ctrl+C to copy. Sized to fill
|
||||
// the available space minus the footer.
|
||||
string joined = lines.Count == 0
|
||||
? "(no messages yet)"
|
||||
: string.Join("\n", lines.Select(l => l.Text));
|
||||
renderer.TextMultilineReadOnly(
|
||||
"##chattailcopy", joined,
|
||||
new System.Numerics.Vector2(0f, -footerHeight));
|
||||
}
|
||||
else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
|
|
@ -127,7 +174,7 @@ public sealed class ChatPanel : IPanel
|
|||
}
|
||||
_lastRenderedCount = lines.Count;
|
||||
}
|
||||
renderer.EndChild();
|
||||
if (!_copyMode) renderer.EndChild();
|
||||
|
||||
// Phase I.4: input field. Backend implementation clears _input
|
||||
// on submit per the IPanelRenderer contract.
|
||||
|
|
@ -153,6 +200,7 @@ public sealed class ChatPanel : IPanel
|
|||
if (TryHandleClientCommand(trimmed))
|
||||
{
|
||||
_input = string.Empty;
|
||||
renderer.EndChild(); // outer ##chatbody
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
|
@ -173,6 +221,7 @@ public sealed class ChatPanel : IPanel
|
|||
_vm.ShowSystemMessage(
|
||||
$"Unknown command: {verb}. Type /help for the list of supported commands.");
|
||||
_input = string.Empty;
|
||||
renderer.EndChild(); // outer ##chatbody
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
|
@ -192,6 +241,7 @@ public sealed class ChatPanel : IPanel
|
|||
_input = string.Empty;
|
||||
}
|
||||
|
||||
renderer.EndChild(); // outer ##chatbody
|
||||
renderer.End();
|
||||
}
|
||||
|
||||
|
|
|
|||
28
src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
Normal file
28
src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Audio mixer preferences persisted to <c>settings.json</c>. Drives the
|
||||
/// existing Phase E.2 OpenAL engine — the host wires these values into
|
||||
/// <c>OpenAlAudioEngine.MasterVolume</c> / <c>SfxVolume</c> /
|
||||
/// <c>MusicVolume</c> / <c>AmbientVolume</c> on Save and on startup.
|
||||
///
|
||||
/// <para>
|
||||
/// Defaults match the engine's hard-coded starting values so a user
|
||||
/// who never opens the Audio tab gets identical behaviour to the
|
||||
/// previous env-var-only world.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record AudioSettings(
|
||||
float Master,
|
||||
float Music,
|
||||
float Sfx,
|
||||
float Ambient)
|
||||
{
|
||||
/// <summary>Values used on first launch. Mirror the engine's
|
||||
/// constructor-default Volume properties.</summary>
|
||||
public static AudioSettings Default { get; } = new(
|
||||
Master: 1.0f,
|
||||
Music: 0.7f,
|
||||
Sfx: 1.0f,
|
||||
Ambient: 0.8f);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Per-character preferences persisted to <c>settings.json</c> under
|
||||
/// <c>character[toonName]</c>. Settings on this tab are scoped to a
|
||||
/// single toon; switching characters loads a different bag.
|
||||
///
|
||||
/// <para>
|
||||
/// L.0 scope: <b>local-only</b>. The settings here describe how the
|
||||
/// client UI behaves for the active toon — they don't yet flow to the
|
||||
/// server. When server-sync ships, options like <see cref="AutoAttack"/>
|
||||
/// would be pushed via the retail Player-Options packet.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// MVP shape — four settings only. Easy to grow when more per-toon
|
||||
/// preferences land. Each is value-typed so equality and Cancel-revert
|
||||
/// behave like the other tabs' records.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record CharacterSettings(
|
||||
string DefaultChatChannel, // "Local" / "Allegiance" / "Fellowship" / "General" / etc.
|
||||
bool AutoAttack, // Tap-to-attack continues swinging until target dies
|
||||
bool ConfirmSalvage, // Prompt before salvaging valuable items
|
||||
bool ShowPickupMessages) // "You picked up X" lines in chat
|
||||
{
|
||||
/// <summary>Defaults applied to a fresh character (no settings.json
|
||||
/// entry yet). Conservative — opt-in for AutoAttack, opt-in for
|
||||
/// confirmation prompts, pickup messages on by default.</summary>
|
||||
public static CharacterSettings Default { get; } = new(
|
||||
DefaultChatChannel: "Local",
|
||||
AutoAttack: false,
|
||||
ConfirmSalvage: true,
|
||||
ShowPickupMessages: true);
|
||||
|
||||
/// <summary>Channel-name presets exposed in the dropdown. Order
|
||||
/// roughly matches retail's chat-channel routing.</summary>
|
||||
public static System.Collections.Generic.IReadOnlyList<string> AvailableChannels { get; } = new[]
|
||||
{
|
||||
"Local",
|
||||
"Allegiance",
|
||||
"Fellowship",
|
||||
"General",
|
||||
"Trade",
|
||||
"LFG",
|
||||
"Roleplay",
|
||||
};
|
||||
}
|
||||
44
src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
Normal file
44
src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Chat-related preferences persisted to <c>settings.json</c>. Mixes
|
||||
/// retail's <c>CharacterOptions2</c> chat-channel filter bits (Hear*Chat
|
||||
/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual
|
||||
/// preferences (font size) that don't have a retail bitfield.
|
||||
/// See <c>docs/research/named-retail/acclient.h:3451+</c> for the
|
||||
/// retail bit values.
|
||||
///
|
||||
/// <para>
|
||||
/// L.0 scope: <b>local-only</b> like the rest of L.0. The Hear*Chat
|
||||
/// flags affect client-side <i>display</i> filtering of the existing
|
||||
/// channels — the server still streams every line; the client decides
|
||||
/// what to render. Server-sync arrives in a later phase that flips the
|
||||
/// retail-faithful "tell server which channels I'm subscribed to"
|
||||
/// switch.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ChatSettings(
|
||||
// CharacterOptions2 (32-bit) channel filters.
|
||||
bool HearGeneralChat, // 0x100 — General channel
|
||||
bool HearTradeChat, // 0x200 — Trade channel
|
||||
bool HearLFGChat, // 0x400 — LFG channel
|
||||
bool HearRoleplayChat, // 0x800 — RP channel
|
||||
bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB)
|
||||
bool AppearOffline, // 0x1000 — hide /who status
|
||||
bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
|
||||
bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
|
||||
// Visual / UX (no retail bitfield).
|
||||
float FontSize) // chat panel font, 10..20 pt
|
||||
{
|
||||
/// <summary>Sensible starting values matching the retail "all on" stance.</summary>
|
||||
public static ChatSettings Default { get; } = new(
|
||||
HearGeneralChat: true,
|
||||
HearTradeChat: true,
|
||||
HearLFGChat: true,
|
||||
HearRoleplayChat: true,
|
||||
HearSocietyChat: true,
|
||||
AppearOffline: false,
|
||||
ShowTimestamps: true,
|
||||
FilterProfanity: true,
|
||||
FontSize: 12f);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Display-related preferences persisted to <c>settings.json</c>.
|
||||
/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
|
||||
/// the various <c>ACDREAM_*</c> environment variables for resolution +
|
||||
/// windowed mode with an in-game UI.
|
||||
///
|
||||
/// <para>
|
||||
/// Records are immutable; mutation goes through
|
||||
/// <see cref="SettingsVM.SetDisplay"/> which assigns a new instance via
|
||||
/// <c>with</c>-expressions.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record DisplaySettings(
|
||||
string Resolution,
|
||||
bool Fullscreen,
|
||||
bool VSync,
|
||||
float FieldOfView,
|
||||
float Gamma,
|
||||
bool ShowFps)
|
||||
{
|
||||
/// <summary>Values used on first launch / when settings.json is absent.
|
||||
/// All defaults pinned to the pre-L.0 runtime state — Resolution
|
||||
/// matches the WindowOptions startup size (1280×720), FieldOfView
|
||||
/// matches camera FovY (60°), VSync matches WindowOptions (false),
|
||||
/// ShowFps preserves the perf string in the title bar. Net effect:
|
||||
/// opening Display + Save without touching anything is a complete
|
||||
/// visual no-op.</summary>
|
||||
public static DisplaySettings Default { get; } = new(
|
||||
Resolution: "1280x720",
|
||||
Fullscreen: false,
|
||||
VSync: false,
|
||||
FieldOfView: 60f,
|
||||
Gamma: 1.0f,
|
||||
ShowFps: true);
|
||||
|
||||
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
|
||||
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
|
||||
{
|
||||
"1280x720",
|
||||
"1366x768",
|
||||
"1600x900",
|
||||
"1920x1080",
|
||||
"2560x1440",
|
||||
"3840x2160",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Gameplay-related preferences persisted to <c>settings.json</c>.
|
||||
/// Mirrors a subset of retail's <c>CharacterOption</c> + <c>CharacterOptions2</c>
|
||||
/// bitfield flags (see <c>docs/research/named-retail/acclient.h:3404+</c>).
|
||||
/// Retail names are kept verbatim so future server-sync packs these
|
||||
/// into the wire-format bitmask without renaming.
|
||||
///
|
||||
/// <para>
|
||||
/// L.0 scope: <b>local-only</b>. The brainstorm explicitly deferred
|
||||
/// server sync — on Save these values are persisted to <c>settings.json</c>
|
||||
/// only. A later phase will marshal them into the retail
|
||||
/// <c>CharacterOption</c> packet (<c>0x...</c>) when the protocol work
|
||||
/// for player-options round-trip is in place.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Defaults below are chosen as the typical-user starting point, NOT
|
||||
/// pinned bit-exact to retail's <c>0x50C4A54A</c> / <c>0x948700</c>
|
||||
/// masks (those will become the defaults once server-sync ships and
|
||||
/// the bitmask round-trip is the load-bearing wire format).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record GameplaySettings(
|
||||
// CharacterOption (32-bit) subset — most-used gameplay toggles.
|
||||
bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack
|
||||
bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit
|
||||
bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run
|
||||
bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
|
||||
bool ShowTooltips, // 0x100 — show item tooltips on hover
|
||||
bool VividTargetingIndicator, // 0x8000 — bright targeting reticle
|
||||
bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked
|
||||
bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar
|
||||
bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons
|
||||
bool AllowGive, // 0x40 — accept items handed by other players
|
||||
// CharacterOptions2 (32-bit) subset.
|
||||
bool ShowHelm, // 0x100000 — render helm overlay on character
|
||||
bool ShowCloak, // 0x800000 — render cloak on character
|
||||
bool LockUI, // 0x1000000 — disable panel drag/resize
|
||||
bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags
|
||||
{
|
||||
/// <summary>Sensible starting values for first launch. NOT bit-exact
|
||||
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
|
||||
/// <c>Default_CharacterOptions2 = 0x948700</c> — see class remarks.</summary>
|
||||
public static GameplaySettings Default { get; } = new(
|
||||
AutoTarget: true,
|
||||
AutoRepeatAttack: true,
|
||||
ToggleRun: true,
|
||||
AdvancedCombatUI: false,
|
||||
ShowTooltips: true,
|
||||
VividTargetingIndicator: false,
|
||||
SideBySideVitals: false,
|
||||
CoordinatesOnRadar: false,
|
||||
SpellDuration: true,
|
||||
AllowGive: true,
|
||||
ShowHelm: true,
|
||||
ShowCloak: true,
|
||||
LockUI: false,
|
||||
UseMouseTurning: false);
|
||||
}
|
||||
|
|
@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input;
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: in-game Settings panel for click-to-rebind keymap editing.
|
||||
/// Hidden by default; opens via <c>F11</c> (which fires the
|
||||
/// <see cref="InputAction.ToggleOptionsPanel"/> action) or via the
|
||||
/// View → Settings entry on the main menu bar.
|
||||
/// 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>
|
||||
/// Layout: top row of action buttons (Save / Cancel / Reset all), then
|
||||
/// a sequence of <see cref="IPanelRenderer.CollapsingHeader"/> sections
|
||||
/// matching the retail keymap categories (Movement / Postures / Camera /
|
||||
/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a
|
||||
/// section: action name, current binding(s) summary, "Rebind" button,
|
||||
/// per-action "Reset" button. When a rebind is in progress the Rebind
|
||||
/// button label changes to "Press a key... (Esc to cancel)".
|
||||
/// 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>
|
||||
/// When <see cref="SettingsVM.PendingConflict"/> is non-null, a
|
||||
/// confirmation prompt is rendered ABOVE the rest of the panel (Yes —
|
||||
/// Reassign / No — Keep existing).
|
||||
/// 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
|
||||
|
|
@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel
|
|||
public string Title => "Settings";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>K.3: hidden by default — opened via F11 / View menu.</remarks>
|
||||
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
|
||||
public bool IsVisible { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel
|
|||
renderer.Separator();
|
||||
}
|
||||
|
||||
// Top action buttons.
|
||||
// Top action buttons. Global across all tabs.
|
||||
if (renderer.Button("Save changes")) _vm.Save();
|
||||
renderer.SameLine();
|
||||
if (renderer.Button("Cancel changes")) _vm.Cancel();
|
||||
|
|
@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel
|
|||
|
||||
renderer.Separator();
|
||||
|
||||
// Sections (retail keymap categories).
|
||||
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"))
|
||||
{
|
||||
RenderAudioTab(renderer);
|
||||
renderer.EndTabItem();
|
||||
}
|
||||
if (renderer.BeginTabItem("Gameplay"))
|
||||
{
|
||||
RenderGameplayTab(renderer);
|
||||
renderer.EndTabItem();
|
||||
}
|
||||
if (renderer.BeginTabItem("Chat"))
|
||||
{
|
||||
RenderChatTab(renderer);
|
||||
renderer.EndTabItem();
|
||||
}
|
||||
if (renderer.BeginTabItem("Character"))
|
||||
{
|
||||
RenderCharacterTab(renderer);
|
||||
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,
|
||||
|
|
@ -136,8 +178,272 @@ public sealed class SettingsPanel : IPanel
|
|||
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
|
||||
InputAction.Cheer, InputAction.PointState,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.End();
|
||||
/// <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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the Audio tab — Master + SFX volume sliders (live preview
|
||||
/// against the running OpenAL engine). Music + Ambient fields exist
|
||||
/// in <see cref="AudioSettings"/> and persist round-trip, but their
|
||||
/// sliders are intentionally hidden here because the underlying
|
||||
/// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI
|
||||
/// playback that hasn't shipped yet — exposing the sliders would be
|
||||
/// "moving a knob that does nothing." When R5 lands, restore the
|
||||
/// hidden sliders below and the JSON-persisted values will already
|
||||
/// be in place.
|
||||
/// </summary>
|
||||
private void RenderAudioTab(IPanelRenderer renderer)
|
||||
{
|
||||
var a = _vm.AudioDraft;
|
||||
|
||||
float master = a.Master;
|
||||
if (renderer.SliderFloat("Master", ref master, 0f, 1f))
|
||||
_vm.SetAudio(a with { Master = master });
|
||||
|
||||
float sfx = a.Sfx;
|
||||
if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
|
||||
_vm.SetAudio(a with { Sfx = sfx });
|
||||
|
||||
// Music + Ambient hidden until R5 MIDI / ambient-loop engines
|
||||
// exist. AudioSettings still carries the fields so the JSON
|
||||
// round-trips and a future client doesn't drop them on save.
|
||||
//
|
||||
// float music = a.Music;
|
||||
// if (renderer.SliderFloat("Music", ref music, 0f, 1f))
|
||||
// _vm.SetAudio(a with { Music = music });
|
||||
// float ambient = a.Ambient;
|
||||
// if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
|
||||
// _vm.SetAudio(a with { Ambient = ambient });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Volume changes preview live as you drag. Save persists the "
|
||||
+ "values to settings.json; Cancel reverts to the saved values. "
|
||||
+ "Music + Ambient mixing arrives with R5 MIDI playback.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the Gameplay tab — ~14 toggles ported from retail's
|
||||
/// CharacterOption + CharacterOptions2 bitfields. Local-only this
|
||||
/// phase (no server sync). Grouped into Combat / Display / Interface
|
||||
/// for first-run discoverability.
|
||||
/// </summary>
|
||||
private void RenderGameplayTab(IPanelRenderer renderer)
|
||||
{
|
||||
var g = _vm.GameplayDraft;
|
||||
|
||||
renderer.Text("Combat");
|
||||
renderer.Separator();
|
||||
|
||||
bool autoTarget = g.AutoTarget;
|
||||
if (renderer.Checkbox("Auto-target on attack", ref autoTarget))
|
||||
_vm.SetGameplay(g with { AutoTarget = autoTarget });
|
||||
|
||||
bool autoRepeat = g.AutoRepeatAttack;
|
||||
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
|
||||
_vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat });
|
||||
|
||||
bool toggleRun = g.ToggleRun;
|
||||
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
|
||||
_vm.SetGameplay(g with { ToggleRun = toggleRun });
|
||||
|
||||
bool advCombat = g.AdvancedCombatUI;
|
||||
if (renderer.Checkbox("Show advanced combat UI", ref advCombat))
|
||||
_vm.SetGameplay(g with { AdvancedCombatUI = advCombat });
|
||||
|
||||
bool vivid = g.VividTargetingIndicator;
|
||||
if (renderer.Checkbox("Vivid targeting indicator", ref vivid))
|
||||
_vm.SetGameplay(g with { VividTargetingIndicator = vivid });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.Text("Display");
|
||||
renderer.Separator();
|
||||
|
||||
bool tooltips = g.ShowTooltips;
|
||||
if (renderer.Checkbox("Show item tooltips", ref tooltips))
|
||||
_vm.SetGameplay(g with { ShowTooltips = tooltips });
|
||||
|
||||
bool sideBySide = g.SideBySideVitals;
|
||||
if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide))
|
||||
_vm.SetGameplay(g with { SideBySideVitals = sideBySide });
|
||||
|
||||
bool coords = g.CoordinatesOnRadar;
|
||||
if (renderer.Checkbox("Show coordinates on radar", ref coords))
|
||||
_vm.SetGameplay(g with { CoordinatesOnRadar = coords });
|
||||
|
||||
bool spellDur = g.SpellDuration;
|
||||
if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur))
|
||||
_vm.SetGameplay(g with { SpellDuration = spellDur });
|
||||
|
||||
bool helm = g.ShowHelm;
|
||||
if (renderer.Checkbox("Show helm on character", ref helm))
|
||||
_vm.SetGameplay(g with { ShowHelm = helm });
|
||||
|
||||
bool cloak = g.ShowCloak;
|
||||
if (renderer.Checkbox("Show cloak on character", ref cloak))
|
||||
_vm.SetGameplay(g with { ShowCloak = cloak });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.Text("Interface");
|
||||
renderer.Separator();
|
||||
|
||||
bool allowGive = g.AllowGive;
|
||||
if (renderer.Checkbox("Accept items handed by other players", ref allowGive))
|
||||
_vm.SetGameplay(g with { AllowGive = allowGive });
|
||||
|
||||
bool lockUI = g.LockUI;
|
||||
if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI))
|
||||
_vm.SetGameplay(g with { LockUI = lockUI });
|
||||
|
||||
bool mouseTurn = g.UseMouseTurning;
|
||||
if (renderer.Checkbox("Use mouse turning", ref mouseTurn))
|
||||
_vm.SetGameplay(g with { UseMouseTurning = mouseTurn });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Local-only this phase — values persist to settings.json but "
|
||||
+ "don't yet sync to the server. Server sync arrives in a "
|
||||
+ "follow-up phase.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the Chat tab — channel filters (Hear*Chat), display
|
||||
/// preferences (timestamps / profanity filter / appear offline),
|
||||
/// and a font-size slider. Channel filters affect client-side
|
||||
/// display only this phase — the server still sends every line,
|
||||
/// the client decides what to render.
|
||||
/// </summary>
|
||||
private void RenderChatTab(IPanelRenderer renderer)
|
||||
{
|
||||
var c = _vm.ChatDraft;
|
||||
|
||||
renderer.Text("Channel filters");
|
||||
renderer.Separator();
|
||||
|
||||
bool general = c.HearGeneralChat;
|
||||
if (renderer.Checkbox("General", ref general))
|
||||
_vm.SetChat(c with { HearGeneralChat = general });
|
||||
|
||||
bool trade = c.HearTradeChat;
|
||||
if (renderer.Checkbox("Trade", ref trade))
|
||||
_vm.SetChat(c with { HearTradeChat = trade });
|
||||
|
||||
bool lfg = c.HearLFGChat;
|
||||
if (renderer.Checkbox("LFG (looking for group)", ref lfg))
|
||||
_vm.SetChat(c with { HearLFGChat = lfg });
|
||||
|
||||
bool rp = c.HearRoleplayChat;
|
||||
if (renderer.Checkbox("Roleplay", ref rp))
|
||||
_vm.SetChat(c with { HearRoleplayChat = rp });
|
||||
|
||||
bool society = c.HearSocietyChat;
|
||||
if (renderer.Checkbox("Society (CD / EW / RB)", ref society))
|
||||
_vm.SetChat(c with { HearSocietyChat = society });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.Text("Display");
|
||||
renderer.Separator();
|
||||
|
||||
bool timestamps = c.ShowTimestamps;
|
||||
if (renderer.Checkbox("Show timestamps", ref timestamps))
|
||||
_vm.SetChat(c with { ShowTimestamps = timestamps });
|
||||
|
||||
bool profanity = c.FilterProfanity;
|
||||
if (renderer.Checkbox("Filter profanity", ref profanity))
|
||||
_vm.SetChat(c with { FilterProfanity = profanity });
|
||||
|
||||
bool offline = c.AppearOffline;
|
||||
if (renderer.Checkbox("Appear offline (hide from /who)", ref offline))
|
||||
_vm.SetChat(c with { AppearOffline = offline });
|
||||
|
||||
float fontSize = c.FontSize;
|
||||
if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f))
|
||||
_vm.SetChat(c with { FontSize = fontSize });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Channel filters hide messages from the chat window without "
|
||||
+ "changing your server-side subscriptions. Save persists; "
|
||||
+ "Cancel reverts.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the Character tab — per-toon preferences. The host owns
|
||||
/// the toon-name key; the panel just edits whatever bag the host
|
||||
/// loaded into <see cref="SettingsVM.CharacterDraft"/>.
|
||||
/// </summary>
|
||||
private void RenderCharacterTab(IPanelRenderer renderer)
|
||||
{
|
||||
var c = _vm.CharacterDraft;
|
||||
|
||||
var channels = CharacterSettings.AvailableChannels.ToArray();
|
||||
int idx = System.Array.IndexOf(channels, c.DefaultChatChannel);
|
||||
if (idx < 0) idx = 0;
|
||||
if (renderer.Combo("Default chat channel", ref idx, channels))
|
||||
_vm.SetCharacter(c with { DefaultChatChannel = channels[idx] });
|
||||
|
||||
bool autoAttack = c.AutoAttack;
|
||||
if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack))
|
||||
_vm.SetCharacter(c with { AutoAttack = autoAttack });
|
||||
|
||||
bool confirmSalvage = c.ConfirmSalvage;
|
||||
if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage))
|
||||
_vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage });
|
||||
|
||||
bool pickup = c.ShowPickupMessages;
|
||||
if (renderer.Checkbox("Show pickup messages in chat", ref pickup))
|
||||
_vm.SetCharacter(c with { ShowPickupMessages = pickup });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Per-character preferences — saved per toon under "
|
||||
+ "settings.json's character[\"<toonName>\"]. Local-only this "
|
||||
+ "phase; server-sync arrives later when the protocol "
|
||||
+ "round-trip lands.");
|
||||
}
|
||||
|
||||
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
|
||||
|
|
|
|||
408
src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
Normal file
408
src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-backed persistence for non-keybind settings (Display today; future
|
||||
/// tabs Audio / Gameplay / Chat / Character will be added to the same
|
||||
/// file). Path: <c>%LOCALAPPDATA%\acdream\settings.json</c>. Coexists
|
||||
/// with <c>keybinds.json</c>, which retains its own
|
||||
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
|
||||
///
|
||||
/// <para>
|
||||
/// Schema (current version 1):
|
||||
/// <code>
|
||||
/// {
|
||||
/// "version": 1,
|
||||
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
|
||||
/// }
|
||||
/// </code>
|
||||
/// Unknown top-level keys are preserved on save so future tab additions
|
||||
/// from a newer client don't get clobbered by an older client writing
|
||||
/// out only the sections it knows about.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
private readonly string _path;
|
||||
|
||||
public SettingsStore(string path)
|
||||
{
|
||||
_path = path ?? throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
|
||||
/// <summary>Default path: <c>%LOCALAPPDATA%\acdream\settings.json</c>.</summary>
|
||||
public static string DefaultPath() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"acdream",
|
||||
"settings.json");
|
||||
|
||||
/// <summary>
|
||||
/// Load Display settings. Missing file → <see cref="DisplaySettings.Default"/>.
|
||||
/// Missing individual keys fall back to the corresponding default
|
||||
/// field, so a partial file (e.g. only <c>resolution</c> is set) is
|
||||
/// non-fatal.
|
||||
/// </summary>
|
||||
public DisplaySettings LoadDisplay()
|
||||
{
|
||||
if (!File.Exists(_path)) return DisplaySettings.Default;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("display", out var disp)
|
||||
|| disp.ValueKind != JsonValueKind.Object)
|
||||
return DisplaySettings.Default;
|
||||
|
||||
var d = DisplaySettings.Default;
|
||||
return new DisplaySettings(
|
||||
Resolution: ReadString (disp, "resolution", d.Resolution),
|
||||
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
|
||||
VSync: ReadBool (disp, "vsync", d.VSync),
|
||||
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
|
||||
Gamma: ReadFloat (disp, "gamma", d.Gamma),
|
||||
ShowFps: ReadBool (disp, "showFps", d.ShowFps));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return DisplaySettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save Display settings, preserving any other top-level keys the file
|
||||
/// already contains (e.g. an <c>audio</c> section written by a newer
|
||||
/// client). Unknown keys are round-tripped via raw JSON text so older
|
||||
/// builds don't silently drop them.
|
||||
/// </summary>
|
||||
public void SaveDisplay(DisplaySettings display)
|
||||
=> SaveSection("display", BuildDisplayObject(display));
|
||||
|
||||
/// <summary>
|
||||
/// Load Audio settings. Same fall-back behaviour as
|
||||
/// <see cref="LoadDisplay"/>: missing file → defaults, missing fields
|
||||
/// → per-field defaults, corrupt JSON → defaults.
|
||||
/// </summary>
|
||||
public AudioSettings LoadAudio()
|
||||
{
|
||||
if (!File.Exists(_path)) return AudioSettings.Default;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("audio", out var audio)
|
||||
|| audio.ValueKind != JsonValueKind.Object)
|
||||
return AudioSettings.Default;
|
||||
|
||||
var d = AudioSettings.Default;
|
||||
return new AudioSettings(
|
||||
Master: ReadFloat(audio, "master", d.Master),
|
||||
Music: ReadFloat(audio, "music", d.Music),
|
||||
Sfx: ReadFloat(audio, "sfx", d.Sfx),
|
||||
Ambient: ReadFloat(audio, "ambient", d.Ambient));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return AudioSettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save Audio settings, preserving every other top-level key
|
||||
/// (display, future gameplay/chat/character). Same round-trip
|
||||
/// guarantee as <see cref="SaveDisplay"/>.
|
||||
/// </summary>
|
||||
public void SaveAudio(AudioSettings audio)
|
||||
=> SaveSection("audio", BuildAudioObject(audio));
|
||||
|
||||
/// <summary>
|
||||
/// Load Gameplay settings (subset of retail CharacterOption flags).
|
||||
/// Same fall-back behaviour as <see cref="LoadDisplay"/>.
|
||||
/// </summary>
|
||||
public GameplaySettings LoadGameplay()
|
||||
{
|
||||
if (!File.Exists(_path)) return GameplaySettings.Default;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("gameplay", out var gp)
|
||||
|| gp.ValueKind != JsonValueKind.Object)
|
||||
return GameplaySettings.Default;
|
||||
|
||||
var d = GameplaySettings.Default;
|
||||
return new GameplaySettings(
|
||||
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
|
||||
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
|
||||
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
|
||||
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
|
||||
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
|
||||
VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator),
|
||||
SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals),
|
||||
CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar),
|
||||
SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration),
|
||||
AllowGive: ReadBool(gp, "allowGive", d.AllowGive),
|
||||
ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
|
||||
ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
|
||||
LockUI: ReadBool(gp, "lockUI", d.LockUI),
|
||||
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return GameplaySettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save Gameplay settings, preserving all other top-level keys.</summary>
|
||||
public void SaveGameplay(GameplaySettings gameplay)
|
||||
=> SaveSection("gameplay", BuildGameplayObject(gameplay));
|
||||
|
||||
/// <summary>Load Chat settings. Same fall-back behaviour as <see cref="LoadDisplay"/>.</summary>
|
||||
public ChatSettings LoadChat()
|
||||
{
|
||||
if (!File.Exists(_path)) return ChatSettings.Default;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("chat", out var chat)
|
||||
|| chat.ValueKind != JsonValueKind.Object)
|
||||
return ChatSettings.Default;
|
||||
|
||||
var d = ChatSettings.Default;
|
||||
return new ChatSettings(
|
||||
HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat),
|
||||
HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat),
|
||||
HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat),
|
||||
HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat),
|
||||
HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat),
|
||||
AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline),
|
||||
ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps),
|
||||
FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity),
|
||||
FontSize: ReadFloat(chat, "fontSize", d.FontSize));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return ChatSettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save Chat settings, preserving all other top-level keys.</summary>
|
||||
public void SaveChat(ChatSettings chat)
|
||||
=> SaveSection("chat", BuildChatObject(chat));
|
||||
|
||||
/// <summary>
|
||||
/// Load per-character settings keyed by <paramref name="toonKey"/>.
|
||||
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
|
||||
/// </summary>
|
||||
public CharacterSettings LoadCharacter(string toonKey)
|
||||
{
|
||||
if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
|
||||
if (!File.Exists(_path)) return CharacterSettings.Default;
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
|
||||
var toon = root?["character"]?[toonKey] as JsonObject;
|
||||
if (toon is null) return CharacterSettings.Default;
|
||||
|
||||
var d = CharacterSettings.Default;
|
||||
return new CharacterSettings(
|
||||
DefaultChatChannel: toon["defaultChatChannel"]?.GetValue<string>() ?? d.DefaultChatChannel,
|
||||
AutoAttack: toon["autoAttack"]?.GetValue<bool>() ?? d.AutoAttack,
|
||||
ConfirmSalvage: toon["confirmSalvage"]?.GetValue<bool>() ?? d.ConfirmSalvage,
|
||||
ShowPickupMessages: toon["showPickupMessages"]?.GetValue<bool>() ?? d.ShowPickupMessages);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return CharacterSettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save per-character settings under <paramref name="toonKey"/>.
|
||||
/// Preserves every other toon's settings + every other top-level
|
||||
/// section. Uses <see cref="JsonNode"/> rather than the raw-text
|
||||
/// preservation pattern of <see cref="SaveSection"/> because the
|
||||
/// per-toon write needs to mutate a nested map, not just replace a
|
||||
/// top-level key.
|
||||
/// </summary>
|
||||
public void SaveCharacter(string toonKey, CharacterSettings settings)
|
||||
{
|
||||
if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
|
||||
if (settings is null) throw new ArgumentNullException(nameof(settings));
|
||||
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// Read existing file as a mutable JsonObject (or start fresh).
|
||||
JsonObject root;
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
root = new JsonObject();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
root = new JsonObject();
|
||||
}
|
||||
|
||||
// Build the toon's payload.
|
||||
var toonObj = new JsonObject
|
||||
{
|
||||
["autoAttack"] = settings.AutoAttack,
|
||||
["confirmSalvage"] = settings.ConfirmSalvage,
|
||||
["defaultChatChannel"] = settings.DefaultChatChannel,
|
||||
["showPickupMessages"] = settings.ShowPickupMessages,
|
||||
};
|
||||
|
||||
// Slot it under character[toonKey], creating the character map if
|
||||
// necessary. Other toons in the map are preserved.
|
||||
if (root["character"] is not JsonObject characterMap)
|
||||
{
|
||||
characterMap = new JsonObject();
|
||||
root["character"] = characterMap;
|
||||
}
|
||||
characterMap[toonKey] = toonObj;
|
||||
root["version"] = CurrentSchemaVersion;
|
||||
|
||||
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["appearOffline"] = c.AppearOffline,
|
||||
["filterProfanity"] = c.FilterProfanity,
|
||||
["fontSize"] = c.FontSize,
|
||||
["hearGeneralChat"] = c.HearGeneralChat,
|
||||
["hearLFGChat"] = c.HearLFGChat,
|
||||
["hearRoleplayChat"] = c.HearRoleplayChat,
|
||||
["hearSocietyChat"] = c.HearSocietyChat,
|
||||
["hearTradeChat"] = c.HearTradeChat,
|
||||
["showTimestamps"] = c.ShowTimestamps,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildGameplayObject(GameplaySettings g)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["advancedCombatUI"] = g.AdvancedCombatUI,
|
||||
["allowGive"] = g.AllowGive,
|
||||
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
||||
["autoTarget"] = g.AutoTarget,
|
||||
["coordinatesOnRadar"] = g.CoordinatesOnRadar,
|
||||
["lockUI"] = g.LockUI,
|
||||
["showCloak"] = g.ShowCloak,
|
||||
["showHelm"] = g.ShowHelm,
|
||||
["showTooltips"] = g.ShowTooltips,
|
||||
["sideBySideVitals"] = g.SideBySideVitals,
|
||||
["spellDuration"] = g.SpellDuration,
|
||||
["toggleRun"] = g.ToggleRun,
|
||||
["useMouseTurning"] = g.UseMouseTurning,
|
||||
["vividTargetingIndicator"] = g.VividTargetingIndicator,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildDisplayObject(DisplaySettings d)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["fieldOfView"] = d.FieldOfView,
|
||||
["fullscreen"] = d.Fullscreen,
|
||||
["gamma"] = d.Gamma,
|
||||
["resolution"] = d.Resolution,
|
||||
["showFps"] = d.ShowFps,
|
||||
["vsync"] = d.VSync,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildAudioObject(AudioSettings a)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["ambient"] = a.Ambient,
|
||||
["master"] = a.Master,
|
||||
["music"] = a.Music,
|
||||
["sfx"] = a.Sfx,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Generic atomic-section save: writes the named section and preserves
|
||||
/// all other top-level keys from the existing file, replacing only the
|
||||
/// version + the targeted section. Avoids duplication between the
|
||||
/// per-section Save methods.
|
||||
/// </summary>
|
||||
private void SaveSection(string sectionName, SortedDictionary<string, object> sectionPayload)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// Preserve any non-target top-level keys from the existing file.
|
||||
var preservedKeys = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == sectionName || prop.Name == "version") continue;
|
||||
preservedKeys[prop.Name] = prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Corrupt file → fully overwrite; previous content is lost
|
||||
// but the user's session continues with the new save.
|
||||
preservedKeys.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append('{').AppendLine();
|
||||
// Preserved keys come first (sorted by name) then the section, then
|
||||
// version last. Preserves alphabetical-style top-level ordering.
|
||||
foreach (var kv in preservedKeys)
|
||||
{
|
||||
sb.Append(" \"").Append(kv.Key).Append("\": ")
|
||||
.Append(kv.Value).Append(',').AppendLine();
|
||||
}
|
||||
sb.Append(" \"").Append(sectionName).Append("\": ")
|
||||
.Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true })
|
||||
.Replace("\n", "\n "))
|
||||
.Append(',').AppendLine();
|
||||
sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine();
|
||||
sb.Append('}').AppendLine();
|
||||
|
||||
File.WriteAllText(_path, sb.ToString());
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement obj, string name, string fallback)
|
||||
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String
|
||||
? (el.GetString() ?? fallback) : fallback;
|
||||
|
||||
private static bool ReadBool(JsonElement obj, string name, bool fallback)
|
||||
=> obj.TryGetProperty(name, out var el)
|
||||
&& (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False)
|
||||
? el.GetBoolean() : fallback;
|
||||
|
||||
private static float ReadFloat(JsonElement obj, string name, float fallback)
|
||||
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
|
||||
? el.GetSingle() : fallback;
|
||||
}
|
||||
|
|
@ -30,6 +30,32 @@ public sealed class SettingsVM
|
|||
private readonly InputDispatcher _dispatcher;
|
||||
private readonly Action<KeyBindings> _onSave;
|
||||
|
||||
// L.0 — Display tab. Treated as a single immutable record; mutation
|
||||
// through SetDisplay clones via with-expressions on the panel side.
|
||||
private DisplaySettings _displayPersisted;
|
||||
private DisplaySettings _displayDraft;
|
||||
private readonly Action<DisplaySettings> _onSaveDisplay;
|
||||
|
||||
// L.0 — Audio tab. Same shape as Display.
|
||||
private AudioSettings _audioPersisted;
|
||||
private AudioSettings _audioDraft;
|
||||
private readonly Action<AudioSettings> _onSaveAudio;
|
||||
|
||||
// L.0 — Gameplay tab (subset of retail CharacterOption flags).
|
||||
private GameplaySettings _gameplayPersisted;
|
||||
private GameplaySettings _gameplayDraft;
|
||||
private readonly Action<GameplaySettings> _onSaveGameplay;
|
||||
|
||||
// L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
|
||||
private ChatSettings _chatPersisted;
|
||||
private ChatSettings _chatDraft;
|
||||
private readonly Action<ChatSettings> _onSaveChat;
|
||||
|
||||
// L.0 — Character tab (per-toon, host-keyed by toon name).
|
||||
private CharacterSettings _characterPersisted;
|
||||
private CharacterSettings _characterDraft;
|
||||
private readonly Action<CharacterSettings> _onSaveCharacter;
|
||||
|
||||
/// <summary>The action currently being rebound, or null when idle.</summary>
|
||||
public InputAction? RebindInProgress { get; private set; }
|
||||
|
||||
|
|
@ -50,14 +76,139 @@ public sealed class SettingsVM
|
|||
/// <summary>True iff the draft differs structurally from the
|
||||
/// persisted snapshot. Used to grey out the Save button when no
|
||||
/// rebinds are pending.</summary>
|
||||
public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft);
|
||||
public bool HasUnsavedChanges
|
||||
=> !KeyBindingsEqual(_persisted, _draft)
|
||||
|| _displayPersisted != _displayDraft
|
||||
|| _audioPersisted != _audioDraft
|
||||
|| _gameplayPersisted != _gameplayDraft
|
||||
|| _chatPersisted != _chatDraft
|
||||
|| _characterPersisted != _characterDraft;
|
||||
|
||||
public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action<KeyBindings> onSave)
|
||||
/// <summary>The current Display draft. Panel reads from here;
|
||||
/// mutation goes through <see cref="SetDisplay"/>.</summary>
|
||||
public DisplaySettings DisplayDraft => _displayDraft;
|
||||
|
||||
/// <summary>The current Audio draft. Panel reads from here;
|
||||
/// mutation goes through <see cref="SetAudio"/>.</summary>
|
||||
public AudioSettings AudioDraft => _audioDraft;
|
||||
|
||||
/// <summary>The current Gameplay draft. Panel reads from here;
|
||||
/// mutation goes through <see cref="SetGameplay"/>.</summary>
|
||||
public GameplaySettings GameplayDraft => _gameplayDraft;
|
||||
|
||||
/// <summary>The current Chat draft. Panel reads from here;
|
||||
/// mutation goes through <see cref="SetChat"/>.</summary>
|
||||
public ChatSettings ChatDraft => _chatDraft;
|
||||
|
||||
/// <summary>The current Character draft (per-toon — host owns the
|
||||
/// toon-name key). Panel reads from here; mutation goes through
|
||||
/// <see cref="SetCharacter"/>.</summary>
|
||||
public CharacterSettings CharacterDraft => _characterDraft;
|
||||
|
||||
public SettingsVM(
|
||||
KeyBindings persisted,
|
||||
InputDispatcher dispatcher,
|
||||
Action<KeyBindings> onSave,
|
||||
DisplaySettings persistedDisplay,
|
||||
Action<DisplaySettings> onSaveDisplay,
|
||||
AudioSettings persistedAudio,
|
||||
Action<AudioSettings> onSaveAudio,
|
||||
GameplaySettings persistedGameplay,
|
||||
Action<GameplaySettings> onSaveGameplay,
|
||||
ChatSettings persistedChat,
|
||||
Action<ChatSettings> onSaveChat,
|
||||
CharacterSettings persistedCharacter,
|
||||
Action<CharacterSettings> onSaveCharacter)
|
||||
{
|
||||
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
|
||||
_draft = CloneBindings(persisted);
|
||||
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
|
||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
|
||||
_displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
|
||||
_onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
|
||||
_audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio));
|
||||
_onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio));
|
||||
_gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay));
|
||||
_onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay));
|
||||
_chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat));
|
||||
_onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat));
|
||||
_characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter));
|
||||
_onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter));
|
||||
_draft = CloneBindings(persisted);
|
||||
_displayDraft = persistedDisplay;
|
||||
_audioDraft = persistedAudio;
|
||||
_gameplayDraft = persistedGameplay;
|
||||
_chatDraft = persistedChat;
|
||||
_characterDraft = persistedCharacter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Display draft with <paramref name="value"/>.
|
||||
/// Panel calls this with a <c>DisplayDraft with { Field = newValue }</c>
|
||||
/// so each widget edits exactly one field at a time.
|
||||
/// </summary>
|
||||
public void SetDisplay(DisplaySettings value)
|
||||
{
|
||||
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Audio draft with <paramref name="value"/>.
|
||||
/// Live audio preview is achieved at the host layer by pushing
|
||||
/// <see cref="AudioDraft"/> into the running OpenAL engine each frame
|
||||
/// — this method only mutates VM state. Cancel reverts the draft and
|
||||
/// the host's next-frame push restores the pre-edit engine volumes.
|
||||
/// </summary>
|
||||
public void SetAudio(AudioSettings value)
|
||||
{
|
||||
_audioDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Gameplay draft with <paramref name="value"/>.
|
||||
/// Local-only this phase — values persist on Save but don't yet
|
||||
/// flow to the server. When server-sync ships, the host's
|
||||
/// <c>onSaveGameplay</c> callback will marshal the draft into the
|
||||
/// retail <c>CharacterOption</c> wire bitmask.
|
||||
/// </summary>
|
||||
public void SetGameplay(GameplaySettings value)
|
||||
{
|
||||
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Chat draft with <paramref name="value"/>.
|
||||
/// Local-only this phase — values persist on Save but the Hear*Chat
|
||||
/// flags affect client-side display filtering, not server-side
|
||||
/// channel subscriptions.
|
||||
/// </summary>
|
||||
public void SetChat(ChatSettings value)
|
||||
{
|
||||
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Character draft with <paramref name="value"/>.
|
||||
/// Per-toon — the host knows which toon's bag we're editing because
|
||||
/// it owned the toonKey when constructing the VM.
|
||||
/// </summary>
|
||||
public void SetCharacter(CharacterSettings 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>
|
||||
|
|
@ -160,32 +311,58 @@ public sealed class SettingsVM
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire draft with <see cref="KeyBindings.RetailDefaults"/>.
|
||||
/// Replace the keybinds draft with <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// AND the display draft with <see cref="DisplaySettings.Default"/>.
|
||||
/// "Reset all" applies to every tab — it's the user's escape hatch
|
||||
/// when they've gotten lost.
|
||||
/// </summary>
|
||||
public void ResetAllToDefaults()
|
||||
{
|
||||
_draft = KeyBindings.RetailDefaults();
|
||||
_draft = KeyBindings.RetailDefaults();
|
||||
_displayDraft = DisplaySettings.Default;
|
||||
_audioDraft = AudioSettings.Default;
|
||||
_gameplayDraft = GameplaySettings.Default;
|
||||
_chatDraft = ChatSettings.Default;
|
||||
_characterDraft = CharacterSettings.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commit the draft via the onSave callback supplied at
|
||||
/// construction. After save the draft becomes the new persisted
|
||||
/// snapshot — <see cref="HasUnsavedChanges"/> resets to false.
|
||||
/// Commit both keybinds + display drafts via the onSave callbacks
|
||||
/// supplied at construction. After save the drafts become the new
|
||||
/// persisted snapshots — <see cref="HasUnsavedChanges"/> resets to
|
||||
/// false. Each callback is invoked exactly once per Save; if the
|
||||
/// caller wants atomicity across both files it has to handle it
|
||||
/// outside the VM.
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
_onSave(_draft);
|
||||
_persisted = CloneBindings(_draft);
|
||||
_onSaveDisplay(_displayDraft);
|
||||
_onSaveAudio(_audioDraft);
|
||||
_onSaveGameplay(_gameplayDraft);
|
||||
_onSaveChat(_chatDraft);
|
||||
_onSaveCharacter(_characterDraft);
|
||||
_persisted = CloneBindings(_draft);
|
||||
_displayPersisted = _displayDraft;
|
||||
_audioPersisted = _audioDraft;
|
||||
_gameplayPersisted = _gameplayDraft;
|
||||
_chatPersisted = _chatDraft;
|
||||
_characterPersisted = _characterDraft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revert the draft to the persisted snapshot and clear any
|
||||
/// Revert all drafts to their persisted snapshots and clear any
|
||||
/// in-flight rebind state. Used by the panel's "Cancel" button and
|
||||
/// when the user closes the settings window without saving.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_draft = CloneBindings(_persisted);
|
||||
_draft = CloneBindings(_persisted);
|
||||
_displayDraft = _displayPersisted;
|
||||
_audioDraft = _audioPersisted;
|
||||
_gameplayDraft = _gameplayPersisted;
|
||||
_chatDraft = _chatPersisted;
|
||||
_characterDraft = _characterPersisted;
|
||||
CancelRebind();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,12 +155,41 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
|
|||
|
||||
/// <inheritdoc />
|
||||
public bool BeginChild(string id, Vector2 size, bool border = false)
|
||||
{
|
||||
// ImGuiChildFlags has changed names across ImGui.NET versions
|
||||
// (Border vs Borders); 0x01 is the stable bit value for "draw
|
||||
// a border". Casting from a numeric literal sidesteps the
|
||||
// version-skew without requiring a hard reference to either
|
||||
// enum spelling.
|
||||
=> ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
|
||||
bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
|
||||
if (open)
|
||||
{
|
||||
// Title-bar-only drag fix (chat tail specifically): empty
|
||||
// clicks inside a scrollable child fall through to the
|
||||
// parent window for drag-init, which is exactly what the
|
||||
// user reported in the chat panel ("clicking anywhere
|
||||
// moves the window"). An InvisibleButton sized to the
|
||||
// child's content region absorbs those clicks so they
|
||||
// don't propagate. Real widgets drawn afterwards still
|
||||
// claim their own clicks (click priority = "last drawn,
|
||||
// first checked"). Wheel scrolling is window-level, not
|
||||
// item-level, so the absorber doesn't interfere with
|
||||
// the chat tail's auto-scroll.
|
||||
//
|
||||
// Scoped to BeginChild only (NOT Begin) because Begin's
|
||||
// body might host tab bars whose hit-testing competes with
|
||||
// an absorber on equal terms — adding it at Begin level
|
||||
// broke Settings tab clicks.
|
||||
var avail = ImGuiNET.ImGui.GetContentRegionAvail();
|
||||
if (avail.X > 0f && avail.Y > 0f)
|
||||
{
|
||||
var savedCursor = ImGuiNET.ImGui.GetCursorPos();
|
||||
ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail);
|
||||
ImGuiNET.ImGui.SetCursorPos(savedCursor);
|
||||
}
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EndChild() => ImGuiNET.ImGui.EndChild();
|
||||
|
|
@ -193,4 +222,35 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
|
|||
=> shortcut is null
|
||||
? ImGuiNET.ImGui.MenuItem(label)
|
||||
: ImGuiNET.ImGui.MenuItem(label, shortcut);
|
||||
|
||||
// -- Tab bar -----------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
|
||||
|
||||
// -- Selectable / copyable text ---------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public void TextMultilineReadOnly(string id, string content, Vector2 size)
|
||||
{
|
||||
// ImGui's InputTextMultiline takes a `ref string` even with the
|
||||
// ReadOnly flag — we just hand it a local copy. maxLength caps
|
||||
// what the user could type if ReadOnly were ever cleared; we
|
||||
// size it to the current content (+1 for ImGui's internal NUL
|
||||
// terminator in some bindings). Min of 1 keeps the empty case
|
||||
// from confusing native bindings.
|
||||
string buffer = content;
|
||||
uint maxLen = (uint)System.Math.Max(content.Length + 1, 1);
|
||||
ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size,
|
||||
ImGuiInputTextFlags.ReadOnly);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue