refactor(settings): OP9 — retire the dead F11 settings surface + fully-superseded GameplaySettings
Campaign OP slice OP9 code retirement (docs/plans/2026-08-10-options-panel-campaign.md
§OP9). The retail four-tab Options panel (OP1-OP8) is now acdream's one
in-client settings surface (D1) — this commit deletes the pre-retail-UI
surfaces it fully superseded. Pure retirement: no behavior change to
anything live, verified by dispositioning every one of the 15 src files
and 8 test files that referenced the deleted types before touching any
of them.
1. SettingsVM + SettingsPanel (the old F11 IPanel surface, unrendered
since Campaign V slice V11's ImGui deletion) deleted outright, plus
their two dedicated test files. IPanel/IPanelRenderer stay — ChatPanel/
DebugPanel/VitalsPanel still implement IPanel, so the contract does NOT
become unreferenced.
2. RuntimeSettingsController's SettingsVM binding seam deleted:
CreateViewModel, CreateViewModelBinding, RuntimeSettingsViewModelBinding,
the _viewModel field, UnbindViewModel, and every _viewModel? notification
call (ToggleFrameRate, LoadCharacterContext, RestoreDefaultCharacterContext,
SyncChatFromServerOptions). CreateViewModelBinding had zero production
callers (test-only). HasDraftPreview/DisplayPreview/AudioPreview stay on
IRuntimeSettingsPreviewSource (WorldRenderFrameBuilder and
SettingsParticleRangeSource still consume it) but now trivially mirror
the committed Display/Audio snapshot — HasDraftPreview was already
always false in production. GameWindowLifetime's matching
"settings view model" shutdown stage is deleted.
3. IDevToolsGameplayCommands + DevToolsGameplayCommands deleted from
GameplayInputCommandController.cs. All three members were dead:
ToggleSettingsPanel() had ZERO dispatch sites (ToggleOptionsPanel always
routed to _retained, never to _devTools); ToggleDebugPanel()/
FocusChatInput() had dispatch sites (F1/Ctrl+F1, Tab) but empty no-op
bodies (their ImGui DebugPanel/ChatPanel targets were already gone).
The two live dispatch sites are kept as inert `return true;` cases
(still consuming the key, matching the prior no-op's "handled"
contract) instead of falling through to a lower-priority scope.
SessionPlayerComposition.cs's `new DevToolsGameplayCommands()`
construction is removed. No `ToggleSettingsPanel` InputAction exists
(only `ToggleOptionsPanel`, rebound at OP3) — nothing to remove there
or from KeyBindings.RetailDefaults()/keymap fixtures.
4. GameplaySettings deleted entirely (the type, SettingsStore's
LoadGameplay/SaveGameplay/BuildGameplayObject, RuntimeSettingsController's
Gameplay property/SetAcceptLootPermits). Verified all 13 remaining
members (ToggleRun, AdvancedCombatUI, ShowTooltips,
VividTargetingIndicator, SideBySideVitals, CoordinatesOnRadar,
SpellDuration, AllowGive, ShowHelm, ShowCloak, LockUI, UseMouseTurning,
AcceptLootPermits — the three combat ones already died at OP4/AP-196)
were ALREADY bound through CharacterOptionTable/
CharacterOptionsPageController's server-bit seam at OP4 before deleting
the client-local mirror — no (c)-case genuinely-client-local member
was found; disposition (b) covers 100% of the surface. SetUiLocked
rewritten to compare only against the last value actually pushed to
_runtimeTargets (MUST-FIX 4's guard), with no second store left to
read or write. LiveSessionRuntimeFactory's SetAcceptLootPermits binding
now sends the wire option only (the GameplaySettings write-behind call
removed as dead output). CharacterSettings/DisplaySettings/
AudioSettings/ChatSettings and their SettingsStore Load/Save surfaces
are UNTOUCHED per the campaign contract.
Per-file disposition (15 src + 8 test files that referenced
GameplaySettings before this commit):
- GameplaySettings.cs, SettingsVM.cs, SettingsPanel.cs: the types
themselves — deleted.
- SettingsStore.cs, RuntimeSettingsController.cs,
LiveSessionRuntimeFactory.cs: real usage — API deleted/rewritten.
- RetailUiRuntime.cs, InteractionRetainedUiComposition.cs,
SessionPlayerComposition.cs, CombatUiController.cs,
LiveCombatAttackOperations.cs, LivePresentationComposition.cs,
FrameRootComposition.cs, RuntimeCharacterState.cs,
CombatCameraTargetSource.cs: doc-comment-only or interface-name
substring matches (ICombatGameplaySettingsSource) — left as accurate
historical record, no forward reference to the deleted type.
- Tests: RuntimeSettingsControllerTests.cs and SettingsStoreTests.cs
rewritten (Gameplay-specific tests deleted; SaveDisplay/SaveAudio/
SaveChat tests re-targeted off the now-public methods instead of the
retired SettingsVM draft/Save() indirection); GameplaySettingsTests.cs/
SettingsVMTests.cs/SettingsPanelTests.cs deleted; the remaining three
(CharacterOptionCombatSettingsSourceTests.cs,
CombatCameraTargetSourceTests.cs, LiveCombatAttackOperationsTests.cs)
were comment/interface-name-only, untouched.
5. Register: AP-196 (OP4's partial GameplaySettings retirement, which left
five fields as write-behind mirrors) is fully retired now that the
record is gone outright — marked ~~AP-196~~ RETIRED with its retirement
note, active-row count 143 -> 142. No other row cited the deleted types
directly (AP-194/AP-193 cite CharacterOptionTable.cs, not
GameplaySettings.cs).
6. Settings.json migration honesty: SettingsStore no longer reads or
writes the "gameplay" top-level key, so an existing file carrying one
from a pre-OP9 build is neither parsed nor dropped — the existing
SaveSection raw-JSON-text preservation mechanism (unknown top-level
keys survive every subsequent save) carries it forward untouched.
Two new targeted tests
(LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads,
LeftoverGameplaySection_SurvivesAnUnrelatedSave) pin this.
InputAction.ToggleOptionsPanel's stale doc comment (still describing the
retired ImGui SettingsPanel) and a handful of other dangling doc
references (DisplaySettings.cs, ChatOpacityLink.cs,
SettingsDevToolsComposition.cs, InputDispatcherCaptureTests.cs) are
reworded to point at the current retail Options panel / OP8
KeyboardConfigController.
Build: dotnet build -c Release green, 0 errors. Tests: dotnet test -c
Release --no-build — 13,075 passed / 4 skipped / 0 failed (13,079 total),
down from the stated baseline of 13,155 passed / 4 skipped / 0 failed
(13,159 total) — the -80 delta is exactly the deleted SettingsVM/
SettingsPanel/GameplaySettings test surface (three whole files plus the
Gameplay-specific cases trimmed from RuntimeSettingsControllerTests.cs/
SettingsStoreTests.cs), with zero regressions elsewhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1a57f96efe
commit
371197a345
22 changed files with 228 additions and 3260 deletions
|
|
@ -1194,7 +1194,6 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
var commands = new GameplayInputCommandController(
|
||||
new RetainedGameplayWindowCommands(
|
||||
interaction.RetainedUi?.Runtime),
|
||||
new DevToolsGameplayCommands(),
|
||||
runtimeDiagnostics,
|
||||
new PlayerModeGameplayCommands(
|
||||
d.PlayerMode,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ namespace AcDream.App.Composition;
|
|||
/// ImGui developer-tools frontend that used to compose here (VitalsPanel,
|
||||
/// ChatPanel, DebugPanel, SettingsPanel via <c>AcDream.UI.ImGui</c>) was
|
||||
/// removed at Campaign V slice V11 along with the OpenGL backend it
|
||||
/// required — see docs/plans/2026-07-27-vulkan-campaign.md. A follow-up
|
||||
/// re-homes the Settings and Debug panels onto the retained UI through a new
|
||||
/// <c>IPanelRenderer</c> implementation; until then keybind remapping falls
|
||||
/// back to editing keybinds.json.
|
||||
/// required — see docs/plans/2026-07-27-vulkan-campaign.md. The
|
||||
/// ImGui-era SettingsPanel's promised re-home landed as Campaign OP's retail
|
||||
/// Options panel (<c>OptionsPanelController</c> et al., the retained
|
||||
/// <c>UiHost</c>/<c>UiRoot</c> tree — D1) instead of a new
|
||||
/// <c>IPanelRenderer</c> implementation, and its OP9 closeout retired the
|
||||
/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping
|
||||
/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to
|
||||
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202).
|
||||
/// </summary>
|
||||
internal sealed record SettingsDevToolsResult(
|
||||
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality);
|
||||
|
|
|
|||
|
|
@ -41,37 +41,6 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
|
|||
_runtime?.ToggleWindow(WindowNames.Options);
|
||||
}
|
||||
|
||||
internal interface IDevToolsGameplayCommands
|
||||
{
|
||||
void ToggleDebugPanel();
|
||||
|
||||
void FocusChatInput();
|
||||
|
||||
void ToggleSettingsPanel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ImGui developer-tools frontend these commands used to forward to was
|
||||
/// removed at Campaign V slice V11. A follow-up re-homes the Settings and
|
||||
/// Debug panels onto the retained UI through a new <c>IPanelRenderer</c>
|
||||
/// implementation; until then these are no-ops so the frozen gameplay-action
|
||||
/// priority graph keeps a single, always-valid handoff target.
|
||||
/// </summary>
|
||||
internal sealed class DevToolsGameplayCommands : IDevToolsGameplayCommands
|
||||
{
|
||||
public void ToggleDebugPanel()
|
||||
{
|
||||
}
|
||||
|
||||
public void FocusChatInput()
|
||||
{
|
||||
}
|
||||
|
||||
public void ToggleSettingsPanel()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IPlayerModeGameplayCommands
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
|
|
@ -163,7 +132,6 @@ internal interface IGameplayInputCommandTarget
|
|||
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
|
||||
{
|
||||
private readonly IRetainedGameplayWindowCommands _retained;
|
||||
private readonly IDevToolsGameplayCommands _devTools;
|
||||
private readonly IRuntimeDiagnosticCommands _diagnostics;
|
||||
private readonly IPlayerModeGameplayCommands _playerMode;
|
||||
private readonly IItemTargetModeCommands _targetMode;
|
||||
|
|
@ -175,7 +143,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
|
||||
public GameplayInputCommandController(
|
||||
IRetainedGameplayWindowCommands retained,
|
||||
IDevToolsGameplayCommands devTools,
|
||||
IRuntimeDiagnosticCommands diagnostics,
|
||||
IPlayerModeGameplayCommands playerMode,
|
||||
IItemTargetModeCommands targetMode,
|
||||
|
|
@ -186,7 +153,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
Action? toggleAudioMute = null)
|
||||
{
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
|
||||
|
|
@ -224,7 +190,14 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_toggleAudioMute?.Invoke();
|
||||
return true;
|
||||
case InputAction.AcdreamToggleDebugPanel:
|
||||
_devTools.ToggleDebugPanel();
|
||||
// OP9: IDevToolsGameplayCommands.ToggleDebugPanel() retired —
|
||||
// its ImGui-era DebugPanel target was already gone (Campaign V
|
||||
// slice V11) and no retained-UI replacement exists, so the
|
||||
// no-op body it forwarded to carried no behavior to preserve.
|
||||
// The key is still consumed here (matching that no-op's
|
||||
// "handled" contract) rather than falling through to whatever
|
||||
// a lower-priority input scope would otherwise do with
|
||||
// F1/Ctrl+F1.
|
||||
return true;
|
||||
case InputAction.AcdreamToggleFlyMode:
|
||||
_playerMode.ToggleFlyOrChase();
|
||||
|
|
@ -233,15 +206,20 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
|
|||
_playerMode.TogglePlayerMode();
|
||||
return true;
|
||||
case InputAction.ToggleChatEntry:
|
||||
_devTools.FocusChatInput();
|
||||
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
|
||||
// same shape as AcdreamToggleDebugPanel above (its ImGui
|
||||
// ChatPanel target was already gone). Tab is still consumed
|
||||
// here, matching the prior no-op's "handled" contract.
|
||||
return true;
|
||||
case InputAction.ToggleOptionsPanel:
|
||||
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
|
||||
// panel now, not the old (unrendered since Campaign V slice
|
||||
// V11 — DevToolsGameplayCommands' own doc) ImGui-era Settings
|
||||
// panel. IDevToolsGameplayCommands.ToggleSettingsPanel() is a
|
||||
// SEPARATE action retired in OP9; its no-op wiring elsewhere
|
||||
// is untouched by this change (#358's lesson).
|
||||
// V11) ImGui-era Settings panel. That panel's own
|
||||
// IDevToolsGameplayCommands.ToggleSettingsPanel() member was
|
||||
// never dispatched from anywhere (this switch always routed
|
||||
// ToggleOptionsPanel to _retained, never to _devTools) and
|
||||
// was retired outright at OP9 along with the rest of
|
||||
// IDevToolsGameplayCommands (#358's lesson).
|
||||
_retained.ToggleOptionsPanel();
|
||||
return true;
|
||||
case InputAction.CombatToggleCombat:
|
||||
|
|
|
|||
|
|
@ -328,8 +328,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
// is a ONE-SHOT assignment taken pre-login, at composition
|
||||
// time, from RuntimeCharacterOptionsState's constructor-
|
||||
// default word. Routing the real value through SetUiLocked
|
||||
// here — the exact seam ToggleUiLock/SaveGameplay already
|
||||
// use to push RuntimeSettingsTargets.ApplyUiLock — converges
|
||||
// here — the exact seam ToggleUiLock already uses to push
|
||||
// RuntimeSettingsTargets.ApplyUiLock — converges
|
||||
// the retained window lock to server truth on every fresh
|
||||
// PlayerDescription, matching the radar's own convergence
|
||||
// instead of only updating on the next manual /lockui toggle.
|
||||
|
|
@ -411,8 +411,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
// immediately, exactly like the Character-tab panel row.
|
||||
// SetUiLocked still runs too, for its existing
|
||||
// _runtimeTargets?.ApplyUiLock(locked) immediate visual push
|
||||
// (host.Root.UiLocked) and its legacy GameplaySettings mirror
|
||||
// (retired in OP9).
|
||||
// (host.Root.UiLocked) — the legacy GameplaySettings mirror it
|
||||
// used to also maintain was retired outright at OP9.
|
||||
ToggleUiLock: () =>
|
||||
{
|
||||
bool locked = !_domain.Character.Options.GetOptionBit(
|
||||
|
|
@ -491,16 +491,15 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
// wire, even though retail auto-saves this id (0x0005
|
||||
// immediately). Now routes through the SAME
|
||||
// SendSingleCharacterOption seam the Character-tab panel row
|
||||
// uses.
|
||||
// uses. OP9 retired the GameplaySettings mirror this used to
|
||||
// ALSO maintain (RuntimeSettingsController.SetAcceptLootPermits)
|
||||
// — the server bit read below is the sole authority now.
|
||||
AcceptLootPermits: () =>
|
||||
_domain.Character.Options.GetOptionBit(
|
||||
CharacterOptionId.AcceptLootPermits),
|
||||
SetAcceptLootPermits: value =>
|
||||
{
|
||||
SendSingleCharacterOption(
|
||||
(uint)CharacterOptionId.AcceptLootPermits, value);
|
||||
_interaction.Settings.SetAcceptLootPermits(value);
|
||||
},
|
||||
(uint)CharacterOptionId.AcceptLootPermits, value),
|
||||
DisplayConsent: session.SendDisplayConsent,
|
||||
ClearConsent: session.SendClearConsent,
|
||||
RemoveConsent: session.SendRemoveConsent,
|
||||
|
|
|
|||
|
|
@ -414,7 +414,6 @@ internal static class GameWindowShutdownManifest
|
|||
]),
|
||||
new ResourceShutdownStage("physical ingress cleanup",
|
||||
[
|
||||
Soft("settings view model", () => ingress.Settings.UnbindViewModel()),
|
||||
Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)),
|
||||
Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)),
|
||||
Soft("retained UI input", ingress.RetailUi.DeactivateInput),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
|
||||
|
|
@ -15,8 +14,6 @@ internal interface IRuntimeSettingsStorage
|
|||
|
||||
AudioSettings LoadAudio();
|
||||
|
||||
GameplaySettings LoadGameplay();
|
||||
|
||||
ChatSettings LoadChat();
|
||||
|
||||
CharacterSettings LoadCharacter(string toonKey);
|
||||
|
|
@ -29,8 +26,6 @@ internal interface IRuntimeSettingsStorage
|
|||
|
||||
void SaveAudio(AudioSettings audio);
|
||||
|
||||
void SaveGameplay(GameplaySettings gameplay);
|
||||
|
||||
void SaveChat(ChatSettings chat);
|
||||
|
||||
void SaveCharacter(string toonKey, CharacterSettings character);
|
||||
|
|
@ -57,8 +52,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
|
|||
|
||||
public AudioSettings LoadAudio() => _store.LoadAudio();
|
||||
|
||||
public GameplaySettings LoadGameplay() => _store.LoadGameplay();
|
||||
|
||||
public ChatSettings LoadChat() => _store.LoadChat();
|
||||
|
||||
public CharacterSettings LoadCharacter(string toonKey) =>
|
||||
|
|
@ -70,9 +63,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
|
|||
|
||||
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
|
||||
|
||||
public void SaveGameplay(GameplaySettings gameplay) =>
|
||||
_store.SaveGameplay(gameplay);
|
||||
|
||||
public void SaveChat(ChatSettings chat) => _store.SaveChat(chat);
|
||||
|
||||
public void SaveCharacter(string toonKey, CharacterSettings character) =>
|
||||
|
|
@ -85,39 +75,10 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
|
|||
internal sealed record RuntimeSettingsSnapshot(
|
||||
DisplaySettings Display,
|
||||
AudioSettings Audio,
|
||||
GameplaySettings Gameplay,
|
||||
ChatSettings Chat,
|
||||
CharacterSettings Character,
|
||||
QualitySettings Quality);
|
||||
|
||||
/// <summary>
|
||||
/// Expected-owner lease for the optional developer settings view model.
|
||||
/// Failed optional composition can withdraw only the instance it installed.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeSettingsViewModelBinding : IDisposable
|
||||
{
|
||||
private readonly RuntimeSettingsController _owner;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeSettingsViewModelBinding(
|
||||
RuntimeSettingsController owner,
|
||||
SettingsVM viewModel)
|
||||
{
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
|
||||
}
|
||||
|
||||
public SettingsVM ViewModel { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_owner.UnbindViewModel(ViewModel);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IRuntimeSettingsStartupTarget
|
||||
{
|
||||
void ApplyDisplay(DisplaySettings display);
|
||||
|
|
@ -186,16 +147,15 @@ internal sealed class RuntimeSettingsController :
|
|||
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
|
||||
private readonly Action<string> _log;
|
||||
private IRuntimeSettingsTargets? _runtimeTargets;
|
||||
private SettingsVM? _viewModel;
|
||||
private CharacterSettings _defaultCharacter;
|
||||
private bool _startupDisplayApplied;
|
||||
private bool _startupAudioApplied;
|
||||
private bool _startupApplied;
|
||||
private bool _uiLockConverged = true;
|
||||
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last
|
||||
// `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` —
|
||||
// the guard `SetUiLocked` compares against, decoupled from whatever
|
||||
// `Gameplay.LockUI`'s own persisted/draft snapshot currently holds.
|
||||
// the guard `SetUiLocked` compares against. Originally decoupled from
|
||||
// the retired client-local `GameplaySettings.LockUI` mirror (OP9
|
||||
// deleted that record outright); this is now the ONLY store.
|
||||
private bool? _lastAppliedUiLocked;
|
||||
|
||||
public RuntimeSettingsController(
|
||||
|
|
@ -209,7 +169,6 @@ internal sealed class RuntimeSettingsController :
|
|||
|
||||
Display = _storage.LoadDisplay();
|
||||
Audio = _storage.LoadAudio();
|
||||
Gameplay = _storage.LoadGameplay();
|
||||
Chat = _storage.LoadChat();
|
||||
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
|
||||
Character = _defaultCharacter;
|
||||
|
|
@ -217,7 +176,6 @@ internal sealed class RuntimeSettingsController :
|
|||
Startup = new RuntimeSettingsSnapshot(
|
||||
Display,
|
||||
Audio,
|
||||
Gameplay,
|
||||
Chat,
|
||||
Character,
|
||||
ResolvedQuality);
|
||||
|
|
@ -233,19 +191,24 @@ internal sealed class RuntimeSettingsController :
|
|||
|
||||
public AudioSettings Audio { get; private set; }
|
||||
|
||||
public GameplaySettings Gameplay { get; private set; }
|
||||
|
||||
public ChatSettings Chat { get; private set; }
|
||||
|
||||
public CharacterSettings Character { get; private set; }
|
||||
|
||||
public QualitySettings ResolvedQuality { get; private set; }
|
||||
|
||||
public bool HasDraftPreview => _viewModel is not null;
|
||||
// OP9: the optional developer-tools draft-preview view model
|
||||
// (SettingsVM) was retired — it had zero production construction
|
||||
// sites (only tests ever called CreateViewModel). HasDraftPreview is
|
||||
// therefore always false in production and DisplayPreview/AudioPreview
|
||||
// always mirror the committed Display/Audio snapshot; the properties
|
||||
// stay on IRuntimeSettingsPreviewSource because WorldRenderFrameBuilder
|
||||
// and SettingsParticleRangeSource still consume the interface.
|
||||
public bool HasDraftPreview => false;
|
||||
|
||||
public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display;
|
||||
public DisplaySettings DisplayPreview => Display;
|
||||
|
||||
public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio;
|
||||
public AudioSettings AudioPreview => Audio;
|
||||
|
||||
public void ApplyStartup(IRuntimeSettingsStartupTarget target)
|
||||
{
|
||||
|
|
@ -316,94 +279,23 @@ internal sealed class RuntimeSettingsController :
|
|||
.UnbindRuntimeTargets(_expected);
|
||||
}
|
||||
|
||||
public SettingsVM CreateViewModel(
|
||||
KeyBindings persistedBindings,
|
||||
InputDispatcher dispatcher,
|
||||
Action<KeyBindings> saveBindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(persistedBindings);
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(saveBindings);
|
||||
if (_viewModel is not null)
|
||||
throw new InvalidOperationException("A settings view model is already bound.");
|
||||
|
||||
_viewModel = new SettingsVM(
|
||||
persistedBindings,
|
||||
dispatcher,
|
||||
saveBindings,
|
||||
Display,
|
||||
SaveDisplay,
|
||||
Audio,
|
||||
SaveAudio,
|
||||
Gameplay,
|
||||
SaveGameplay,
|
||||
Chat,
|
||||
SaveChat,
|
||||
Character,
|
||||
SaveCharacter);
|
||||
return _viewModel;
|
||||
}
|
||||
|
||||
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
|
||||
KeyBindings persistedBindings,
|
||||
InputDispatcher dispatcher,
|
||||
Action<KeyBindings> saveBindings) =>
|
||||
new(
|
||||
this,
|
||||
CreateViewModel(persistedBindings, dispatcher, saveBindings));
|
||||
|
||||
public void UnbindViewModel(SettingsVM? expected = null)
|
||||
{
|
||||
if (expected is null || ReferenceEquals(_viewModel, expected))
|
||||
_viewModel = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OP9: rewritten off the retired client-local <c>GameplaySettings.LockUI</c>
|
||||
/// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the
|
||||
/// guard now compares directly against <see cref="_lastAppliedUiLocked"/>,
|
||||
/// the last value actually pushed to <see cref="_runtimeTargets"/>, with
|
||||
/// no persisted store of its own left to read or write: the server bit
|
||||
/// (<c>RuntimeCharacterOptionsState</c>, read through
|
||||
/// <c>CharacterOptionId.LockUI</c>) is the sole authority, exactly as
|
||||
/// D7's Group-C re-point already made it at OP4.
|
||||
/// </summary>
|
||||
public void SetUiLocked(bool locked)
|
||||
{
|
||||
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
|
||||
// guard used to compare `locked` against `Gameplay.LockUI` — valid
|
||||
// only while `ToggleUiLock` computed `locked` AS `!Gameplay.LockUI`
|
||||
// (pre-OP4). OP4 re-pointed `ToggleUiLock` to derive `locked` from
|
||||
// the SERVER bit (`RuntimeCharacterOptionsState.GetOptionBit`)
|
||||
// instead, a DIFFERENT store that can already equal the persisted
|
||||
// `Gameplay.LockUI` without this method ever having pushed that
|
||||
// value to `_runtimeTargets` — the guard must compare against what
|
||||
// was ACTUALLY applied, not a value from an unrelated store.
|
||||
if (_lastAppliedUiLocked == locked && _uiLockConverged)
|
||||
if (_lastAppliedUiLocked == locked)
|
||||
return;
|
||||
|
||||
_uiLockConverged = false;
|
||||
Gameplay = Gameplay with { LockUI = locked };
|
||||
_runtimeTargets?.ApplyUiLock(locked);
|
||||
_lastAppliedUiLocked = locked;
|
||||
_viewModel?.SetGameplay(
|
||||
_viewModel.GameplayDraft with { LockUI = locked });
|
||||
|
||||
try
|
||||
{
|
||||
_storage.SaveGameplay(Gameplay);
|
||||
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
|
||||
{
|
||||
LockUI = locked,
|
||||
});
|
||||
_uiLockConverged = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"settings: radar lock save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAcceptLootPermits(bool enabled)
|
||||
{
|
||||
Gameplay = Gameplay with { AcceptLootPermits = enabled };
|
||||
_viewModel?.SetGameplay(
|
||||
_viewModel.GameplayDraft with { AcceptLootPermits = enabled });
|
||||
_storage.SaveGameplay(Gameplay);
|
||||
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
|
||||
{
|
||||
AcceptLootPermits = enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -415,10 +307,6 @@ internal sealed class RuntimeSettingsController :
|
|||
public void ToggleFrameRate()
|
||||
{
|
||||
Display = Display with { ShowFps = !Display.ShowFps };
|
||||
_viewModel?.ApplyExternalDisplayChange(display => display with
|
||||
{
|
||||
ShowFps = Display.ShowFps,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -434,7 +322,7 @@ internal sealed class RuntimeSettingsController :
|
|||
/// Campaign OP slice OP3: the five client-local preferences the "Use
|
||||
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
|
||||
/// directly through storage (no startup-snapshot cache, unlike
|
||||
/// <see cref="Display"/>/<see cref="Gameplay"/>/<see cref="Chat"/>) —
|
||||
/// <see cref="Display"/>/<see cref="Chat"/>) —
|
||||
/// this section has no UI surface of its own yet (Campaign OP slice
|
||||
/// OP6's Config tab), so there is nothing today that needs a cached,
|
||||
/// change-notified copy.
|
||||
|
|
@ -468,14 +356,12 @@ internal sealed class RuntimeSettingsController :
|
|||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
ActiveToonKey = characterName;
|
||||
Character = _storage.LoadCharacter(characterName);
|
||||
_viewModel?.LoadCharacterContext(Character);
|
||||
_log($"settings: loaded character[{characterName}] preferences");
|
||||
}
|
||||
|
||||
public void RestoreDefaultCharacterContext()
|
||||
{
|
||||
Character = _defaultCharacter;
|
||||
_viewModel?.LoadCharacterContext(Character);
|
||||
}
|
||||
|
||||
public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey;
|
||||
|
|
@ -500,7 +386,7 @@ internal sealed class RuntimeSettingsController :
|
|||
/// same shape as the already-public <see cref="SaveCameraTurning"/>,
|
||||
/// now also called directly by <c>ConfigOptionsPageController</c>'s
|
||||
/// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality
|
||||
/// family) rather than only through the (soon-retired) SettingsVM
|
||||
/// family) rather than only through the (OP9-retired) SettingsVM
|
||||
/// callback wiring.</summary>
|
||||
public void SaveDisplay(DisplaySettings display)
|
||||
{
|
||||
|
|
@ -540,24 +426,6 @@ internal sealed class RuntimeSettingsController :
|
|||
}
|
||||
}
|
||||
|
||||
private void SaveGameplay(GameplaySettings gameplay)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.SaveGameplay(gameplay);
|
||||
Gameplay = gameplay;
|
||||
_uiLockConverged = false;
|
||||
_runtimeTargets?.ApplyUiLock(gameplay.LockUI);
|
||||
_lastAppliedUiLocked = gameplay.LockUI;
|
||||
_uiLockConverged = true;
|
||||
_log($"settings: gameplay saved to {_storage.Location}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"settings: gameplay save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Widened from private to public at Campaign OP slice OP6,
|
||||
/// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat
|
||||
/// Font Face/Size rows (store-only, no diff against the five wired
|
||||
|
|
@ -580,7 +448,7 @@ internal sealed class RuntimeSettingsController :
|
|||
|
||||
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
|
||||
// SetSingleCharacterOption (0x0005) in the same step (mirrors
|
||||
// SaveGameplay's ApplyUiLock push above) — ACE's handler both flips
|
||||
// SetUiLocked's ApplyUiLock push above) — ACE's handler both flips
|
||||
// the option AND joins/leaves the matching Turbine room.
|
||||
PublishHearOptionChange(
|
||||
previous.HearGeneralChat, chat.HearGeneralChat,
|
||||
|
|
@ -627,11 +495,10 @@ internal sealed class RuntimeSettingsController :
|
|||
/// </summary>
|
||||
public void SyncChatFromServerOptions(uint options2)
|
||||
{
|
||||
// Applied identically to BOTH the persisted snapshot and the live
|
||||
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
|
||||
// shape) so an unsaved draft edit to an unrelated field (font size,
|
||||
// timestamps, ...) survives the reseed instead of being clobbered by
|
||||
// a value computed once against the persisted snapshot.
|
||||
// Reseed function applied to the persisted snapshot below so an
|
||||
// unsaved edit to an unrelated field (font size, timestamps, ...)
|
||||
// survives the reseed instead of being clobbered by a value
|
||||
// computed once against a stale snapshot.
|
||||
ChatSettings Reseed(ChatSettings current) => current with
|
||||
{
|
||||
HearGeneralChat = (options2
|
||||
|
|
@ -651,7 +518,6 @@ internal sealed class RuntimeSettingsController :
|
|||
return;
|
||||
|
||||
Chat = synced;
|
||||
_viewModel?.ApplyExternalChatChange(Reseed);
|
||||
try
|
||||
{
|
||||
_storage.SaveChat(synced);
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ public enum InputAction
|
|||
/// <summary>Toggle the world / map panel (retail F10).</summary>
|
||||
ToggleWorldPanel,
|
||||
/// <summary>Toggle the options / settings panel (retail F11) — opens
|
||||
/// our SettingsPanel in K.3.</summary>
|
||||
/// the retail Options panel (Campaign OP, D1). Superseded the K.3-era
|
||||
/// ImGui SettingsPanel this action originally opened; that surface was
|
||||
/// never rendered after Campaign V slice V11 and was retired outright
|
||||
/// at OP9.</summary>
|
||||
ToggleOptionsPanel,
|
||||
/// <summary>Toggle the inventory panel (retail F12).</summary>
|
||||
ToggleInventoryPanel,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
|
|||
/// each other while dragging.
|
||||
///
|
||||
/// <para>
|
||||
/// Shared by two independent consumers so both apply the identical link math:
|
||||
/// <c>RetailWindowOpacityController</c> (AcDream.App — the live per-window
|
||||
/// mechanism, mutating its own two float fields) and <see cref="SettingsPanel"/>'s
|
||||
/// Chat tab (the draft slider UI, mutating a <see cref="ChatSettings"/> record).
|
||||
/// Pure functions — no window/render/state dependency — so both layers can call
|
||||
/// them without violating the App→Abstractions dependency direction.
|
||||
/// Consumed directly by <c>RetailWindowOpacityController</c> (AcDream.App — the
|
||||
/// live per-window mechanism, mutating its own two float fields). The retail
|
||||
/// Options panel's Chat tab (<c>ChatOptionsPageController</c>, Campaign OP slice
|
||||
/// OP5) drives that SAME controller rather than calling these methods a second
|
||||
/// time, so the panel's two sliders and every retained window's live fade always
|
||||
/// apply the identical link math through one path. (The OP9-retired
|
||||
/// <c>SettingsPanel</c>'s own Chat tab used to call these methods directly as a
|
||||
/// second consumer, mutating a draft <see cref="ChatSettings"/> record — that
|
||||
/// draft mechanism is gone, not the link math.) Pure functions — no
|
||||
/// window/render/state dependency — so any future caller can use them without
|
||||
/// violating the App→Abstractions dependency direction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatOpacityLink
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ public enum ParticleRange
|
|||
/// did not change when these three take effect, only how they're reached.
|
||||
///
|
||||
/// <para>
|
||||
/// Records are immutable; mutation goes through
|
||||
/// <see cref="SettingsVM.SetDisplay"/> which assigns a new instance via
|
||||
/// <c>with</c>-expressions.
|
||||
/// Records are immutable; mutation goes through <c>with</c>-expressions —
|
||||
/// e.g. <c>RuntimeSettingsController.SaveDisplay</c>'s callers, or the
|
||||
/// OP9-retired <c>SettingsVM.SetDisplay</c>'s draft assignment before that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record DisplaySettings(
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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.
|
||||
//
|
||||
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): AutoTarget,
|
||||
// AutoRepeatAttack, and ViewCombatTarget were REMOVED from this record
|
||||
// — the Combat panel's three LEDs now read/write the SAME canonical
|
||||
// server-bit seam (RuntimeCharacterOptionsState via
|
||||
// CharacterOptionCombatSettingsSource) the Character tab's rows for
|
||||
// these same retail PlayerOptions already used, closing the
|
||||
// "two writable copies" divergence (register row AP-196). This record
|
||||
// remains the client-local persistence/draft store for every OTHER
|
||||
// gameplay preference that has no such server-authoritative seam.
|
||||
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
|
||||
bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions
|
||||
{
|
||||
/// <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(
|
||||
ToggleRun: true,
|
||||
AdvancedCombatUI: false,
|
||||
ShowTooltips: true,
|
||||
VividTargetingIndicator: true,
|
||||
SideBySideVitals: false,
|
||||
// Retail default character-options mask 0x50C4A54A includes
|
||||
// CoordinatesOnRadar (0x00400000).
|
||||
CoordinatesOnRadar: true,
|
||||
SpellDuration: true,
|
||||
AllowGive: true,
|
||||
ShowHelm: true,
|
||||
ShowCloak: true,
|
||||
LockUI: false,
|
||||
UseMouseTurning: false,
|
||||
// Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear.
|
||||
AcceptLootPermits: false);
|
||||
}
|
||||
|
|
@ -1,561 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// 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>
|
||||
/// 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
|
||||
{
|
||||
private readonly SettingsVM _vm;
|
||||
|
||||
public SettingsPanel(SettingsVM vm)
|
||||
{
|
||||
_vm = vm ?? throw new System.ArgumentNullException(nameof(vm));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "acdream.settings";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Title => "Settings";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
|
||||
public bool IsVisible { get; set; } = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Render(PanelContext ctx, IPanelRenderer renderer)
|
||||
{
|
||||
if (!renderer.Begin(Title))
|
||||
{
|
||||
renderer.End();
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict prompt — modal-ish row at top of the panel.
|
||||
if (_vm.PendingConflict is { } conflict)
|
||||
{
|
||||
renderer.TextWrapped(
|
||||
$"'{ChordLabel(conflict.NewChord)}' is already bound to "
|
||||
+ $"{conflict.ConflictingAction}. Reassign it to "
|
||||
+ $"{conflict.NewAction}?");
|
||||
if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true);
|
||||
renderer.SameLine();
|
||||
if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false);
|
||||
renderer.Separator();
|
||||
}
|
||||
|
||||
// Top action buttons. Global across all tabs.
|
||||
if (renderer.Button("Save changes")) _vm.Save();
|
||||
renderer.SameLine();
|
||||
if (renderer.Button("Cancel changes")) _vm.Cancel();
|
||||
renderer.SameLine();
|
||||
if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults();
|
||||
|
||||
renderer.Separator();
|
||||
|
||||
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,
|
||||
InputAction.MovementTurnLeft, InputAction.MovementTurnRight,
|
||||
InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight,
|
||||
InputAction.MovementJump, InputAction.MovementStop,
|
||||
InputAction.MovementWalkMode, InputAction.MovementRunLock,
|
||||
});
|
||||
RenderSection(renderer, "Postures", new[]
|
||||
{
|
||||
InputAction.Ready, InputAction.Sitting,
|
||||
InputAction.Crouch, InputAction.Sleeping,
|
||||
});
|
||||
RenderSection(renderer, "Camera", new[]
|
||||
{
|
||||
InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook,
|
||||
InputAction.CameraRotateLeft, InputAction.CameraRotateRight,
|
||||
InputAction.CameraRotateUp, InputAction.CameraRotateDown,
|
||||
InputAction.CameraMoveToward, InputAction.CameraMoveAway,
|
||||
InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson,
|
||||
InputAction.CameraViewLookDown, InputAction.CameraViewMapMode,
|
||||
});
|
||||
RenderSection(renderer, "Combat", new[]
|
||||
{
|
||||
InputAction.CombatToggleCombat,
|
||||
InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower,
|
||||
InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack,
|
||||
InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh,
|
||||
InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab,
|
||||
InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell,
|
||||
});
|
||||
RenderSection(renderer, "UI panels", new[]
|
||||
{
|
||||
InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel,
|
||||
InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel,
|
||||
InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel,
|
||||
InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel,
|
||||
InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel,
|
||||
InputAction.SelectionExamine, InputAction.UseSelected,
|
||||
InputAction.EscapeKey, InputAction.LOGOUT,
|
||||
});
|
||||
RenderSection(renderer, "Chat", new[]
|
||||
{
|
||||
InputAction.ToggleChatEntry, InputAction.EnterChatMode,
|
||||
InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2,
|
||||
InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4,
|
||||
});
|
||||
RenderSection(renderer, "Hotbar", new[]
|
||||
{
|
||||
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,
|
||||
InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6,
|
||||
InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9,
|
||||
InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3,
|
||||
InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6,
|
||||
InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9,
|
||||
InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16,
|
||||
InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18,
|
||||
InputAction.CreateShortcut,
|
||||
});
|
||||
RenderSection(renderer, "Emotes", new[]
|
||||
{
|
||||
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
|
||||
InputAction.Cheer, InputAction.PointState,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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 });
|
||||
|
||||
// A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA,
|
||||
// anisotropic level, A2C, and max completions-per-frame as a unit.
|
||||
// Resolution + anisotropic + A2C + completions apply immediately via
|
||||
// ReapplyQualityPreset; MSAA samples require a restart (GL context
|
||||
// cannot change sample count at runtime).
|
||||
var presets = s_qualityPresetNames;
|
||||
int qIdx = (int)d.Quality;
|
||||
if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High;
|
||||
if (renderer.Combo("Quality", ref qIdx, presets))
|
||||
_vm.SetDisplay(d with { Quality = (QualityPreset)qIdx });
|
||||
|
||||
int particleRangeIndex = (int)d.ParticleRange;
|
||||
if (particleRangeIndex < 0 || particleRangeIndex >= s_particleRangeNames.Length)
|
||||
particleRangeIndex = (int)DisplaySettings.Default.ParticleRange;
|
||||
if (renderer.Combo("Particle Range", ref particleRangeIndex, s_particleRangeNames))
|
||||
_vm.SetDisplay(d with { ParticleRange = (ParticleRange)particleRangeIndex });
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
|
||||
+ "preview live as you drag; Cancel reverts to the saved value. "
|
||||
+ "Quality preset applies streaming radius, anisotropic, and A2C "
|
||||
+ "immediately on Save; MSAA sample count requires a restart. "
|
||||
+ "Particle Range defaults to Extended, which doubles authored "
|
||||
+ "effect distance; Retail restores the exact client cutoff.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the Audio tab — Master, SFX and Ambient volume sliders, all with
|
||||
/// live preview against the running engine.
|
||||
///
|
||||
/// <para>
|
||||
/// Ambient was hidden until Campaign A slice A5 because nothing drove it;
|
||||
/// the region ambient soundscape now does, so the knob moves something and
|
||||
/// is exposed. The Music slider is gone entirely: retail has no music
|
||||
/// system, so there was never anything for it to turn down.
|
||||
/// </para>
|
||||
/// </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 });
|
||||
|
||||
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();
|
||||
|
||||
// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
|
||||
// Auto-target/Auto-repeat/Keep-in-view were removed from
|
||||
// GameplaySettings — they now read/write the canonical server bit
|
||||
// (RuntimeCharacterOptionsState) through CombatUiController, not
|
||||
// this client-local draft. This panel has no production
|
||||
// construction site (D.2b's retained UiHost/UiRoot stack is the
|
||||
// one presentation surface — see CLAUDE.md's UI strategy section);
|
||||
// the three checkboxes are simply retired rather than re-pointed
|
||||
// to a seam this dat-free ImGui-shaped panel has no way to reach.
|
||||
|
||||
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.Text("Window transparency");
|
||||
renderer.Separator();
|
||||
|
||||
// Campaign CH slice CH6c: retail's two linked opacity sliders
|
||||
// (gmChatOptionsUI::InitOptions @0x0049FC60's DualHash pair). Dragging
|
||||
// Background above Active drags Active UP to match; dragging Active below
|
||||
// Background drags Background DOWN — ChatOpacityLink is the shared port of
|
||||
// ChatInterface::SetDefaultOpacity/SetActiveOpacity (0x004F3BC0/0x004F3C40)
|
||||
// that both this draft and the live RetailWindowOpacityController use, so the
|
||||
// sliders track each other exactly like retail's options page.
|
||||
float defaultOpacity = c.DefaultOpacity;
|
||||
if (renderer.SliderFloat("Background opacity (unfocused)", ref defaultOpacity, 0f, 1f))
|
||||
{
|
||||
var (def, active) = ChatOpacityLink.SetDefault(c.ActiveOpacity, defaultOpacity);
|
||||
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
|
||||
}
|
||||
|
||||
float activeOpacity = c.ActiveOpacity;
|
||||
if (renderer.SliderFloat("Active opacity (typing / focused)", ref activeOpacity, 0f, 1f))
|
||||
{
|
||||
var (def, active) = ChatOpacityLink.SetActive(c.DefaultOpacity, activeOpacity);
|
||||
_vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active });
|
||||
}
|
||||
|
||||
renderer.Spacing();
|
||||
renderer.TextWrapped(
|
||||
"Channel filters hide messages from the chat window without "
|
||||
+ "changing your server-side subscriptions. Window transparency "
|
||||
+ "applies to every retained window (chat, floaties, vitals, "
|
||||
+ "toolbar, inventory...) and fades whichever window doesn't "
|
||||
+ "currently have keyboard focus; Active opacity can never be "
|
||||
+ "lower than Background — dragging one past the other drags "
|
||||
+ "the other along. 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.");
|
||||
}
|
||||
|
||||
// A.5 T22.5: preset label array parallel to QualityPreset enum values.
|
||||
// Order must match the enum (Low=0, Medium=1, High=2, Ultra=3).
|
||||
private static readonly string[] s_qualityPresetNames =
|
||||
{ "Low", "Medium", "High", "Ultra" };
|
||||
|
||||
private static readonly string[] s_particleRangeNames =
|
||||
{ "Retail", "Extended" };
|
||||
|
||||
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
|
||||
{
|
||||
// Movement defaults open; other sections collapsed for first-run UX.
|
||||
bool defaultOpen = label == "Movement";
|
||||
if (!renderer.CollapsingHeader(label, defaultOpen))
|
||||
return;
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
renderer.Text(action.ToString());
|
||||
renderer.SameLine();
|
||||
|
||||
// Current binding(s) summary.
|
||||
var binds = _vm.Draft.ForAction(action).ToList();
|
||||
string summary = binds.Count == 0
|
||||
? "(unbound)"
|
||||
: string.Join(", ", binds.Select(b => ChordLabel(b.Chord)));
|
||||
renderer.Text(summary);
|
||||
renderer.SameLine();
|
||||
|
||||
// Rebind button — when a rebind is in progress for THIS
|
||||
// action, the label changes to a "press a key..." prompt.
|
||||
// The "##{action}" suffix gives ImGui a stable per-row id
|
||||
// so multiple "Rebind" buttons don't collide.
|
||||
string buttonLabel = (_vm.RebindInProgress == action)
|
||||
? $"Press a key... (Esc to cancel)##{action}"
|
||||
: $"Rebind##{action}";
|
||||
if (renderer.Button(buttonLabel))
|
||||
{
|
||||
if (binds.Count > 0 && _vm.RebindInProgress is null)
|
||||
_vm.BeginRebind(action, binds[0]);
|
||||
}
|
||||
renderer.SameLine();
|
||||
if (renderer.Button($"Reset##{action}"))
|
||||
_vm.ResetActionToDefault(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a chord as <c>"Shift+Ctrl+A"</c> / <c>"W"</c> / etc. for the
|
||||
/// row summary + conflict prompt. Joins held modifiers with <c>+</c>
|
||||
/// then the trigger key name.
|
||||
/// </summary>
|
||||
private static string ChordLabel(KeyChord chord)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift");
|
||||
if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl");
|
||||
if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt");
|
||||
if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win");
|
||||
parts.Add(chord.Key.ToString());
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,11 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
|
|||
public readonly record struct UiWindowPosition(float X, float Y);
|
||||
|
||||
/// <summary>
|
||||
/// JSON-backed persistence for non-keybind settings (Display today; future
|
||||
/// tabs Audio / Gameplay / Chat / Character will be added to the same
|
||||
/// file). The graphical host supplies a canonical portable configuration
|
||||
/// path. Coexists
|
||||
/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
|
||||
/// Character; the OP9-retired <c>Gameplay</c> section is no longer read or
|
||||
/// written — see the class's Schema note below for how a leftover
|
||||
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
|
||||
/// graphical host supplies a canonical portable configuration path. Coexists
|
||||
/// with <c>keybinds.json</c>, which retains its own
|
||||
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
|
||||
///
|
||||
|
|
@ -29,7 +30,11 @@ public readonly record struct UiWindowPosition(float X, float Y);
|
|||
/// </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.
|
||||
/// out only the sections it knows about — the SAME round-trip preservation
|
||||
/// (<see cref="SaveSection"/>'s raw-JSON-text carry-forward) that keeps an
|
||||
/// EXISTING file's now-orphaned <c>"gameplay"</c> block intact forever: no
|
||||
/// <c>Load*</c> method here reads it, so it is neither parsed nor dropped,
|
||||
/// just silently carried along untouched on every subsequent save.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SettingsStore
|
||||
|
|
@ -149,49 +154,6 @@ public sealed class SettingsStore
|
|||
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(
|
||||
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),
|
||||
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
|
||||
}
|
||||
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()
|
||||
{
|
||||
|
|
@ -624,24 +586,6 @@ public sealed class SettingsStore
|
|||
["showTimestamps"] = c.ShowTimestamps,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildGameplayObject(GameplaySettings g)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["advancedCombatUI"] = g.AdvancedCombatUI,
|
||||
["acceptLootPermits"] = g.AcceptLootPermits,
|
||||
["allowGive"] = g.AllowGive,
|
||||
["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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,451 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3 ViewModel for <see cref="SettingsPanel"/>. Owns a <b>draft</b>
|
||||
/// copy of the current <see cref="KeyBindings"/>; rebinds modify the
|
||||
/// draft. <see cref="Save"/> commits draft via the supplied callback
|
||||
/// (which writes to disk + replaces the live dispatcher's table);
|
||||
/// <see cref="Cancel"/> reverts the draft to the persisted state.
|
||||
///
|
||||
/// <para>
|
||||
/// Click-to-rebind UX: caller invokes <see cref="BeginRebind"/> with the
|
||||
/// action being rebound + the binding being replaced. The VM enters
|
||||
/// modal capture on the dispatcher; when the user presses a chord (or
|
||||
/// Esc), the dispatcher reports it via <see cref="OnChordCaptured"/>.
|
||||
/// If the new chord conflicts with another action's binding (same
|
||||
/// activation type), <see cref="PendingConflict"/> surfaces a prompt
|
||||
/// the panel renders as Yes / No buttons; <see cref="ResolveConflict"/>
|
||||
/// dispatches the user's choice.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SettingsVM
|
||||
{
|
||||
private KeyBindings _persisted;
|
||||
private KeyBindings _draft;
|
||||
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; }
|
||||
|
||||
/// <summary>The original binding being replaced (so we can preserve
|
||||
/// activation type on the new chord and roll back on cancel).</summary>
|
||||
public Binding? RebindOriginal { get; private set; }
|
||||
|
||||
/// <summary>The action+chord conflict pending confirmation, or null.
|
||||
/// Populated when <see cref="OnChordCaptured"/> finds the captured
|
||||
/// chord already bound to another action; cleared by
|
||||
/// <see cref="ResolveConflict"/>.</summary>
|
||||
public ConflictPrompt? PendingConflict { get; private set; }
|
||||
|
||||
/// <summary>The current working draft. Panel renders bindings from
|
||||
/// here; mutates via the rebind / reset methods.</summary>
|
||||
public KeyBindings Draft => _draft;
|
||||
|
||||
/// <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)
|
||||
|| _displayPersisted != _displayDraft
|
||||
|| _audioPersisted != _audioDraft
|
||||
|| _gameplayPersisted != _gameplayDraft
|
||||
|| _chatPersisted != _chatDraft
|
||||
|| _characterPersisted != _characterDraft;
|
||||
|
||||
/// <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));
|
||||
_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>
|
||||
/// Apply one externally persisted display-option change to both snapshots.
|
||||
/// Existing unsaved edits to other fields remain drafts rather than being
|
||||
/// accidentally promoted to persisted state.
|
||||
/// </summary>
|
||||
public void ApplyExternalDisplayChange(Func<DisplaySettings, DisplaySettings> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
_displayPersisted = update(_displayPersisted)
|
||||
?? throw new InvalidOperationException("Display update returned null.");
|
||||
_displayDraft = update(_displayDraft)
|
||||
?? throw new InvalidOperationException("Display update returned null.");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Apply one externally persisted gameplay-option change to both snapshots.
|
||||
/// Existing unsaved edits to unrelated fields remain drafts rather than
|
||||
/// being accidentally promoted to persisted state.
|
||||
/// </summary>
|
||||
public void ApplyExternalGameplayChange(
|
||||
Func<GameplaySettings, GameplaySettings> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
_gameplayPersisted = update(_gameplayPersisted)
|
||||
?? throw new InvalidOperationException("Gameplay update returned null.");
|
||||
_gameplayDraft = update(_gameplayDraft)
|
||||
?? throw new InvalidOperationException("Gameplay update returned null.");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Apply one externally learned Chat change (server truth — CH3,
|
||||
/// 2026-08-09) to both snapshots, matching
|
||||
/// <see cref="ApplyExternalGameplayChange"/>'s shape. Existing unsaved
|
||||
/// edits to unrelated fields remain drafts rather than being accidentally
|
||||
/// promoted to persisted state.
|
||||
/// </summary>
|
||||
public void ApplyExternalChatChange(Func<ChatSettings, ChatSettings> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
_chatPersisted = update(_chatPersisted)
|
||||
?? throw new InvalidOperationException("Chat update returned null.");
|
||||
_chatDraft = update(_chatDraft)
|
||||
?? throw new InvalidOperationException("Chat update returned null.");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Begin rebinding <paramref name="action"/>. The supplied
|
||||
/// <paramref name="original"/> binding will be removed when the new
|
||||
/// chord is applied. The dispatcher enters modal capture mode; the
|
||||
/// next chord pressed (or Esc) feeds back into
|
||||
/// <see cref="OnChordCaptured"/>.
|
||||
/// </summary>
|
||||
public void BeginRebind(InputAction action, Binding original)
|
||||
{
|
||||
RebindInProgress = action;
|
||||
RebindOriginal = original;
|
||||
_dispatcher.BeginCapture(OnChordCaptured);
|
||||
}
|
||||
|
||||
private void OnChordCaptured(KeyChord chord)
|
||||
{
|
||||
// Sentinel: dispatcher reports default(KeyChord) on Esc cancel.
|
||||
if (chord.Equals(default(KeyChord)))
|
||||
{
|
||||
RebindInProgress = null;
|
||||
RebindOriginal = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict check: scan the draft for a binding that matches the
|
||||
// captured chord + same activation type, but on a DIFFERENT
|
||||
// action. (Same-action bindings are fine — that's already in
|
||||
// _draft for this action and gets removed when we apply.)
|
||||
var existing = _draft.Find(
|
||||
chord,
|
||||
RebindOriginal!.Value.Activation,
|
||||
RebindOriginal.Value.Scope);
|
||||
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
|
||||
{
|
||||
PendingConflict = new ConflictPrompt(
|
||||
NewAction: RebindInProgress.Value,
|
||||
NewChord: chord,
|
||||
OriginalBinding: RebindOriginal.Value,
|
||||
ConflictingAction: existing.Value.Action,
|
||||
ConflictingBinding: existing.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyRebind(chord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a <see cref="PendingConflict"/>: <paramref name="replace"/>=
|
||||
/// true removes the conflicting binding and applies the new chord;
|
||||
/// false cancels the rebind entirely (original binding intact).
|
||||
/// </summary>
|
||||
public void ResolveConflict(bool replace)
|
||||
{
|
||||
if (PendingConflict is null) return;
|
||||
var c = PendingConflict.Value;
|
||||
if (replace)
|
||||
{
|
||||
_draft.Remove(c.ConflictingBinding);
|
||||
ApplyRebind(c.NewChord);
|
||||
}
|
||||
else
|
||||
{
|
||||
RebindInProgress = null;
|
||||
RebindOriginal = null;
|
||||
}
|
||||
PendingConflict = null;
|
||||
}
|
||||
|
||||
private void ApplyRebind(KeyChord chord)
|
||||
{
|
||||
_draft.Remove(RebindOriginal!.Value);
|
||||
_draft.Add(new Binding(
|
||||
chord,
|
||||
RebindInProgress!.Value,
|
||||
RebindOriginal.Value.Activation,
|
||||
RebindOriginal.Value.Scope));
|
||||
RebindInProgress = null;
|
||||
RebindOriginal = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel any in-progress rebind / pending conflict and clear the
|
||||
/// dispatcher's capture state. Does NOT revert the draft — for that
|
||||
/// see <see cref="Cancel"/>.
|
||||
/// </summary>
|
||||
public void CancelRebind()
|
||||
{
|
||||
if (_dispatcher.IsCapturing) _dispatcher.CancelCapture();
|
||||
RebindInProgress = null;
|
||||
RebindOriginal = null;
|
||||
PendingConflict = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the draft's bindings for <paramref name="action"/> to the
|
||||
/// retail defaults. Other actions' draft bindings are untouched.
|
||||
/// </summary>
|
||||
public void ResetActionToDefault(InputAction action)
|
||||
{
|
||||
var defaults = KeyBindings.RetailDefaults();
|
||||
foreach (var b in _draft.ForAction(action).ToList())
|
||||
_draft.Remove(b);
|
||||
foreach (var b in defaults.ForAction(action))
|
||||
_draft.Add(b);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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();
|
||||
_displayDraft = DisplaySettings.Default;
|
||||
_audioDraft = AudioSettings.Default;
|
||||
_gameplayDraft = GameplaySettings.Default;
|
||||
_chatDraft = ChatSettings.Default;
|
||||
_characterDraft = CharacterSettings.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
_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 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);
|
||||
_displayDraft = _displayPersisted;
|
||||
_audioDraft = _audioPersisted;
|
||||
_gameplayDraft = _gameplayPersisted;
|
||||
_chatDraft = _chatPersisted;
|
||||
_characterDraft = _characterPersisted;
|
||||
CancelRebind();
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static KeyBindings CloneBindings(KeyBindings src)
|
||||
{
|
||||
var clone = new KeyBindings();
|
||||
foreach (var b in src.All) clone.Add(b);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool KeyBindingsEqual(KeyBindings a, KeyBindings b)
|
||||
{
|
||||
if (a.All.Count != b.All.Count) return false;
|
||||
for (int i = 0; i < a.All.Count; i++)
|
||||
if (!a.All[i].Equals(b.All[i])) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K.3 conflict-prompt payload surfaced when the user binds a chord
|
||||
/// already in use. The panel renders the <see cref="NewAction"/> +
|
||||
/// <see cref="ConflictingAction"/> labels in a confirmation prompt;
|
||||
/// <see cref="SettingsVM.ResolveConflict"/> dispatches the user's
|
||||
/// answer.
|
||||
/// </summary>
|
||||
public readonly record struct ConflictPrompt(
|
||||
InputAction NewAction,
|
||||
KeyChord NewChord,
|
||||
Binding OriginalBinding,
|
||||
InputAction ConflictingAction,
|
||||
Binding ConflictingBinding);
|
||||
Loading…
Add table
Add a link
Reference in a new issue