feat(ui): Audio tab — live volume sliders driving OpenAL engine
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.
AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).
SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.
SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.
GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.
SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.
16 new tests:
· AudioSettings record (3) — defaults pin engine constants, value
equality, with-expressions
· SettingsStore audio round-trip (5) — missing-file → defaults,
round-trip all fields, partial-file per-field fallback, save-audio-
preserves-display, save-display-preserves-audio
· SettingsVM audio state (5) — initial draft tracks persisted,
SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
ResetAllToDefaults covers audio
· SettingsPanel audio tab (3) — four sliders render only when active,
no SliderFloat emitted on inactive tabs, slider range is [0, 1]
dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
382f0ad3fa
commit
53b1878c5c
9 changed files with 461 additions and 47 deletions
|
|
@ -82,11 +82,79 @@ public sealed class SettingsStore
|
|||
/// 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));
|
||||
|
||||
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-display top-level keys from the existing file.
|
||||
// Preserve any non-target top-level keys from the existing file.
|
||||
var preservedKeys = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
|
|
@ -96,7 +164,7 @@ public sealed class SettingsStore
|
|||
var doc = JsonDocument.Parse(stream);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Name == "display" || prop.Name == "version") continue;
|
||||
if (prop.Name == sectionName || prop.Name == "version") continue;
|
||||
preservedKeys[prop.Name] = prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
|
|
@ -108,28 +176,19 @@ public sealed class SettingsStore
|
|||
}
|
||||
}
|
||||
|
||||
var displayObj = new SortedDictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["fieldOfView"] = display.FieldOfView,
|
||||
["fullscreen"] = display.Fullscreen,
|
||||
["gamma"] = display.Gamma,
|
||||
["resolution"] = display.Resolution,
|
||||
["showFps"] = display.ShowFps,
|
||||
["vsync"] = display.VSync,
|
||||
};
|
||||
|
||||
// Build the output by hand so preserved-keys keep their raw JSON.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append('{').AppendLine();
|
||||
sb.Append(" \"display\": ")
|
||||
.Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true })
|
||||
.Replace("\n", "\n "))
|
||||
.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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue