refactor(app): compose settings and developer tools
This commit is contained in:
parent
60a1698ce7
commit
cd7b519f78
24 changed files with 2073 additions and 333 deletions
|
|
@ -33,8 +33,25 @@ internal interface ICombatFeedbackSink
|
||||||
|
|
||||||
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
||||||
{
|
{
|
||||||
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
|
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel;
|
||||||
public void Show(string message) => ViewModel?.AddToast(message);
|
|
||||||
|
public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(viewModel);
|
||||||
|
if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Combat feedback is already bound to a developer view model.");
|
||||||
|
_viewModel = viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(viewModel);
|
||||||
|
if (ReferenceEquals(_viewModel, viewModel))
|
||||||
|
_viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show(string message) => _viewModel?.AddToast(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
||||||
|
|
|
||||||
631
src/AcDream.App/Composition/SettingsDevToolsComposition.cs
Normal file
631
src/AcDream.App/Composition/SettingsDevToolsComposition.cs
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
using AcDream.App.Combat;
|
||||||
|
using AcDream.App.Diagnostics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Settings;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Debug;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
|
using AcDream.UI.ImGui;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
internal sealed record SettingsDevToolsResult(
|
||||||
|
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality,
|
||||||
|
DevToolsCompositionOwner? DevTools);
|
||||||
|
|
||||||
|
internal sealed record SettingsDevToolsOptionalDependencies(
|
||||||
|
IDevToolsRuntimeFacts Facts,
|
||||||
|
IRuntimeKeyBindingTarget? KeyBindingTarget,
|
||||||
|
DeferredCanonicalWorldEntityCountSource WorldEntities,
|
||||||
|
DeferredRenderFrameDiagnosticsSource FrameDiagnostics,
|
||||||
|
DeferredDevToolsPlayerModeCommands PlayerModeCommands);
|
||||||
|
|
||||||
|
internal sealed record SettingsDevToolsDependencies(
|
||||||
|
IView Window,
|
||||||
|
RuntimeSettingsController Settings,
|
||||||
|
IRuntimeSettingsStartupTarget StartupTarget,
|
||||||
|
HostQuiescenceGate HostQuiescence,
|
||||||
|
ChatLog Chat,
|
||||||
|
CombatState Combat,
|
||||||
|
LocalPlayerState LocalPlayer,
|
||||||
|
SettingsDevToolsOptionalDependencies? DevTools,
|
||||||
|
RuntimeDiagnosticCommandSlot DiagnosticCommands,
|
||||||
|
CombatFeedbackSlot CombatFeedback,
|
||||||
|
KeyBindings KeyBindings,
|
||||||
|
FrameProfiler FrameProfiler,
|
||||||
|
FramebufferResizeController FramebufferResize,
|
||||||
|
Action<string> Log);
|
||||||
|
|
||||||
|
internal interface IGameWindowSettingsDevToolsPublication
|
||||||
|
{
|
||||||
|
void PublishDevTools(DevToolsCompositionOwner value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ISettingsDevToolsCompositionFactory
|
||||||
|
{
|
||||||
|
bool IsSupported(IInputContext input);
|
||||||
|
|
||||||
|
IDevToolsInputContext CreateInputContext(
|
||||||
|
IInputContext input,
|
||||||
|
HostQuiescenceGate quiescence);
|
||||||
|
|
||||||
|
IImGuiBootstrapper CreateBootstrap(
|
||||||
|
GL gl,
|
||||||
|
IView window,
|
||||||
|
IInputContext input);
|
||||||
|
|
||||||
|
ImGuiPanelHost CreatePanelHost();
|
||||||
|
|
||||||
|
IDevToolsFrameBackend CreateBackend(
|
||||||
|
IImGuiBootstrapper bootstrap,
|
||||||
|
ImGuiPanelHost panels);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RetailSettingsDevToolsCompositionFactory
|
||||||
|
: ISettingsDevToolsCompositionFactory
|
||||||
|
{
|
||||||
|
public bool IsSupported(IInputContext input) =>
|
||||||
|
input.Keyboards.Count > 0
|
||||||
|
&& input.Mice.Count > 0
|
||||||
|
&& input.Mice[0].ScrollWheels.Count > 0;
|
||||||
|
|
||||||
|
public IDevToolsInputContext CreateInputContext(
|
||||||
|
IInputContext input,
|
||||||
|
HostQuiescenceGate quiescence) =>
|
||||||
|
new QuiescentInputContext(input, quiescence);
|
||||||
|
|
||||||
|
public IImGuiBootstrapper CreateBootstrap(
|
||||||
|
GL gl,
|
||||||
|
IView window,
|
||||||
|
IInputContext input) =>
|
||||||
|
new ImGuiBootstrapper(gl, window, input);
|
||||||
|
|
||||||
|
public ImGuiPanelHost CreatePanelHost() => new();
|
||||||
|
|
||||||
|
public IDevToolsFrameBackend CreateBackend(
|
||||||
|
IImGuiBootstrapper bootstrap,
|
||||||
|
ImGuiPanelHost panels) =>
|
||||||
|
new ImGuiDevToolsFrameBackend(bootstrap, panels);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum SettingsDevToolsCompositionPoint
|
||||||
|
{
|
||||||
|
SettingsApplied,
|
||||||
|
InputContextCreated,
|
||||||
|
BootstrapCreated,
|
||||||
|
InputActivated,
|
||||||
|
PanelHostCreated,
|
||||||
|
VitalsRegistered,
|
||||||
|
ChatRegistered,
|
||||||
|
DebugViewModelCreated,
|
||||||
|
DiagnosticActionsBound,
|
||||||
|
CombatFeedbackBound,
|
||||||
|
DebugRegistered,
|
||||||
|
SettingsViewModelBound,
|
||||||
|
SettingsRegistered,
|
||||||
|
BackendCreated,
|
||||||
|
PresenterCreated,
|
||||||
|
FramebufferBound,
|
||||||
|
InitialLayoutApplied,
|
||||||
|
DevToolsPublished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Complete optional developer frontend lifetime. Construction rollback and
|
||||||
|
/// normal shutdown share the same named, retryable release operations.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DevToolsCompositionOwner : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IDevToolsInputContext _input;
|
||||||
|
private readonly IDevToolsFrameBackend _backend;
|
||||||
|
private readonly ChatVM _chat;
|
||||||
|
private readonly DebugVM _debug;
|
||||||
|
private readonly RuntimeSettingsViewModelBinding? _settings;
|
||||||
|
private readonly CombatFeedbackSlot _combatFeedback;
|
||||||
|
private readonly FramebufferDevToolsBinding _framebuffer;
|
||||||
|
private readonly ResourceShutdownTransaction _frontendShutdown;
|
||||||
|
private bool _ownsInput;
|
||||||
|
private bool _ownsBackend;
|
||||||
|
private bool _ownsChat;
|
||||||
|
private bool _ownsDebug;
|
||||||
|
private bool _ownsSettings;
|
||||||
|
private bool _ownsCombatBinding;
|
||||||
|
private bool _ownsFramebuffer;
|
||||||
|
|
||||||
|
public DevToolsCompositionOwner(
|
||||||
|
IDevToolsInputContext input,
|
||||||
|
IDevToolsFrameBackend backend,
|
||||||
|
DevToolsFramePresenter presenter,
|
||||||
|
DevToolsCommandBusSource commandBus,
|
||||||
|
VitalsVM vitals,
|
||||||
|
ChatVM chat,
|
||||||
|
DebugVM debug,
|
||||||
|
SettingsDevToolsOptionalDependencies lateBindings,
|
||||||
|
RuntimeSettingsViewModelBinding? settings,
|
||||||
|
CombatFeedbackSlot combatFeedback,
|
||||||
|
FramebufferDevToolsBinding framebuffer)
|
||||||
|
{
|
||||||
|
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||||
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||||
|
Presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
|
||||||
|
CommandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus));
|
||||||
|
Vitals = vitals ?? throw new ArgumentNullException(nameof(vitals));
|
||||||
|
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||||
|
_debug = debug ?? throw new ArgumentNullException(nameof(debug));
|
||||||
|
LateBindings = lateBindings
|
||||||
|
?? throw new ArgumentNullException(nameof(lateBindings));
|
||||||
|
_settings = settings;
|
||||||
|
_combatFeedback = combatFeedback ?? throw new ArgumentNullException(nameof(combatFeedback));
|
||||||
|
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
|
||||||
|
|
||||||
|
_frontendShutdown = new ResourceShutdownTransaction(
|
||||||
|
new ResourceShutdownStage("developer framebuffer binding",
|
||||||
|
[
|
||||||
|
new("framebuffer resize binding", ReleaseFramebuffer),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("developer backend",
|
||||||
|
[
|
||||||
|
new("ImGui backend", ReleaseBackend),
|
||||||
|
]),
|
||||||
|
new ResourceShutdownStage("developer view models",
|
||||||
|
[
|
||||||
|
new("settings view model binding", ReleaseSettings),
|
||||||
|
new("combat feedback binding", ReleaseCombatBinding),
|
||||||
|
new("debug combat subscriptions", ReleaseDebug),
|
||||||
|
new("chat transcript subscription", ReleaseChat),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public DevToolsFramePresenter Presenter { get; }
|
||||||
|
public DevToolsCommandBusSource CommandBus { get; }
|
||||||
|
public VitalsVM Vitals { get; }
|
||||||
|
public DebugVM Debug => _debug;
|
||||||
|
public SettingsDevToolsOptionalDependencies LateBindings { get; }
|
||||||
|
|
||||||
|
public bool IsDisposalComplete =>
|
||||||
|
_frontendShutdown.IsComplete && !_ownsInput;
|
||||||
|
|
||||||
|
public void AdoptInput() => _ownsInput = true;
|
||||||
|
public void AdoptBackend() => _ownsBackend = true;
|
||||||
|
public void AdoptChat() => _ownsChat = true;
|
||||||
|
public void AdoptDebug() => _ownsDebug = true;
|
||||||
|
public void AdoptSettings() => _ownsSettings = _settings is not null;
|
||||||
|
public void AdoptCombatBinding() => _ownsCombatBinding = true;
|
||||||
|
public void AdoptFramebuffer() => _ownsFramebuffer = true;
|
||||||
|
|
||||||
|
public void CompleteOwnership()
|
||||||
|
{
|
||||||
|
if (!_ownsInput
|
||||||
|
|| !_ownsBackend
|
||||||
|
|| !_ownsChat
|
||||||
|
|| !_ownsDebug
|
||||||
|
|| !_ownsCombatBinding
|
||||||
|
|| !_ownsFramebuffer
|
||||||
|
|| (_settings is not null && !_ownsSettings))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Developer composition ownership is incomplete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeactivateInput()
|
||||||
|
{
|
||||||
|
CommandBus.Deactivate();
|
||||||
|
LateBindings.PlayerModeCommands.Deactivate();
|
||||||
|
LateBindings.FrameDiagnostics.Deactivate();
|
||||||
|
LateBindings.WorldEntities.Deactivate();
|
||||||
|
if (_ownsInput)
|
||||||
|
_input.Deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DetachInput()
|
||||||
|
{
|
||||||
|
if (!_ownsInput)
|
||||||
|
return;
|
||||||
|
_input.Dispose();
|
||||||
|
if (!_input.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Developer input callback cleanup remains incomplete.");
|
||||||
|
_ownsInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DisposeFrontend() => _frontendShutdown.CompleteOrThrow();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DeactivateInput();
|
||||||
|
DisposeFrontend();
|
||||||
|
DetachInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseFramebuffer()
|
||||||
|
{
|
||||||
|
if (!_ownsFramebuffer)
|
||||||
|
return;
|
||||||
|
_framebuffer.Dispose();
|
||||||
|
_ownsFramebuffer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseCombatBinding()
|
||||||
|
{
|
||||||
|
if (!_ownsCombatBinding)
|
||||||
|
return;
|
||||||
|
_combatFeedback.Unbind(_debug);
|
||||||
|
_ownsCombatBinding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSettings()
|
||||||
|
{
|
||||||
|
if (!_ownsSettings)
|
||||||
|
return;
|
||||||
|
_settings!.Dispose();
|
||||||
|
_ownsSettings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseDebug()
|
||||||
|
{
|
||||||
|
if (!_ownsDebug)
|
||||||
|
return;
|
||||||
|
_debug.Dispose();
|
||||||
|
_ownsDebug = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseChat()
|
||||||
|
{
|
||||||
|
if (!_ownsChat)
|
||||||
|
return;
|
||||||
|
_chat.Dispose();
|
||||||
|
_ownsChat = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseBackend()
|
||||||
|
{
|
||||||
|
if (!_ownsBackend)
|
||||||
|
return;
|
||||||
|
_backend.Dispose();
|
||||||
|
_ownsBackend = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CombatFeedbackBinding : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CombatFeedbackSlot _owner;
|
||||||
|
private readonly DebugVM _viewModel;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public CombatFeedbackBinding(CombatFeedbackSlot owner, DebugVM viewModel)
|
||||||
|
{
|
||||||
|
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||||
|
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
|
||||||
|
_owner.Bind(_viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_owner.Unbind(_viewModel);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production Phase 3. Settings are required; the optional developer frontend
|
||||||
|
/// may be abandoned only after every locally acquired edge has been removed.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SettingsDevToolsCompositionPhase :
|
||||||
|
ISettingsDevToolsCompositionPhase<
|
||||||
|
GameWindowPlatformResult<GL, IInputContext>,
|
||||||
|
HostInputCameraResult,
|
||||||
|
ContentEffectsAudioResult,
|
||||||
|
SettingsDevToolsResult>
|
||||||
|
{
|
||||||
|
private readonly SettingsDevToolsDependencies _dependencies;
|
||||||
|
private readonly IGameWindowSettingsDevToolsPublication _publication;
|
||||||
|
private readonly ISettingsDevToolsCompositionFactory _factory;
|
||||||
|
private readonly Action<SettingsDevToolsCompositionPoint>? _faultInjection;
|
||||||
|
|
||||||
|
public SettingsDevToolsCompositionPhase(
|
||||||
|
SettingsDevToolsDependencies dependencies,
|
||||||
|
IGameWindowSettingsDevToolsPublication publication,
|
||||||
|
ISettingsDevToolsCompositionFactory? factory = null,
|
||||||
|
Action<SettingsDevToolsCompositionPoint>? faultInjection = null)
|
||||||
|
{
|
||||||
|
_dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies));
|
||||||
|
_publication = publication ?? throw new ArgumentNullException(nameof(publication));
|
||||||
|
_factory = factory ?? new RetailSettingsDevToolsCompositionFactory();
|
||||||
|
_faultInjection = faultInjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsDevToolsResult Compose(
|
||||||
|
GameWindowPlatformResult<GL, IInputContext> platform,
|
||||||
|
HostInputCameraResult host,
|
||||||
|
ContentEffectsAudioResult content)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(platform);
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
|
|
||||||
|
_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.SettingsApplied);
|
||||||
|
|
||||||
|
DevToolsCompositionOwner? devTools = _dependencies.DevTools is { } optional
|
||||||
|
? ComposeOptionalDevTools(platform, host, optional)
|
||||||
|
: null;
|
||||||
|
return new SettingsDevToolsResult(
|
||||||
|
_dependencies.Settings.ResolvedQuality,
|
||||||
|
devTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DevToolsCompositionOwner? ComposeOptionalDevTools(
|
||||||
|
GameWindowPlatformResult<GL, IInputContext> platform,
|
||||||
|
HostInputCameraResult host,
|
||||||
|
SettingsDevToolsOptionalDependencies optional)
|
||||||
|
{
|
||||||
|
var scope = new CompositionAcquisitionScope();
|
||||||
|
bool published = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_factory.IsSupported(platform.Input))
|
||||||
|
{
|
||||||
|
_dependencies.Log(
|
||||||
|
"devtools: keyboard, mouse, or scroll input unavailable — devtools disabled");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inputLease = scope.Acquire(
|
||||||
|
"developer input context",
|
||||||
|
() => _factory.CreateInputContext(
|
||||||
|
platform.Input,
|
||||||
|
_dependencies.HostQuiescence),
|
||||||
|
static value => value.Dispose());
|
||||||
|
IDevToolsInputContext input = inputLease.Resource;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.InputContextCreated);
|
||||||
|
|
||||||
|
var bootstrapLease = scope.Acquire(
|
||||||
|
"ImGui bootstrap",
|
||||||
|
() => _factory.CreateBootstrap(
|
||||||
|
platform.Graphics,
|
||||||
|
_dependencies.Window,
|
||||||
|
input),
|
||||||
|
static value => value.Dispose());
|
||||||
|
IImGuiBootstrapper bootstrap = bootstrapLease.Resource;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.BootstrapCreated);
|
||||||
|
|
||||||
|
input.Activate();
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.InputActivated);
|
||||||
|
|
||||||
|
ImGuiPanelHost panelHost = _factory.CreatePanelHost();
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.PanelHostCreated);
|
||||||
|
|
||||||
|
var vitals = new VitalsVM(
|
||||||
|
_dependencies.Combat,
|
||||||
|
_dependencies.LocalPlayer);
|
||||||
|
var vitalsPanel = new VitalsPanel(vitals);
|
||||||
|
panelHost.Register(vitalsPanel);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.VitalsRegistered);
|
||||||
|
|
||||||
|
var chatLease = scope.Acquire(
|
||||||
|
"developer chat view model",
|
||||||
|
() => new ChatVM(_dependencies.Chat)
|
||||||
|
{
|
||||||
|
FpsProvider = () => optional.Facts.Fps,
|
||||||
|
PositionProvider = () => optional.Facts.PlayerPosition,
|
||||||
|
},
|
||||||
|
static value => value.Dispose());
|
||||||
|
ChatVM chatVm = chatLease.Resource;
|
||||||
|
var chatPanel = new ChatPanel(chatVm);
|
||||||
|
panelHost.Register(chatPanel);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.ChatRegistered);
|
||||||
|
|
||||||
|
var debugLease = scope.Acquire(
|
||||||
|
"developer debug view model",
|
||||||
|
() => CreateDebugViewModel(optional.Facts),
|
||||||
|
static value => value.Dispose());
|
||||||
|
DebugVM debugVm = debugLease.Resource;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.DebugViewModelCreated);
|
||||||
|
debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;
|
||||||
|
debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;
|
||||||
|
debugVm.ToggleCollisionWires =
|
||||||
|
_dependencies.DiagnosticCommands.ToggleCollisionWireframes;
|
||||||
|
debugVm.ToggleFlyMode = optional.PlayerModeCommands.ToggleFlyOrChase;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.DiagnosticActionsBound);
|
||||||
|
|
||||||
|
var combatBindingLease = scope.Acquire(
|
||||||
|
"combat feedback binding",
|
||||||
|
() => new CombatFeedbackBinding(
|
||||||
|
_dependencies.CombatFeedback,
|
||||||
|
debugVm),
|
||||||
|
static value => value.Dispose());
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.CombatFeedbackBound);
|
||||||
|
|
||||||
|
var debugPanel = new DebugPanel(debugVm);
|
||||||
|
panelHost.Register(debugPanel);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.DebugRegistered);
|
||||||
|
|
||||||
|
RuntimeSettingsViewModelBinding? settingsBinding = null;
|
||||||
|
SettingsPanel? settingsPanel = null;
|
||||||
|
CompositionAcquisitionScope.CompositionAcquisitionLease<RuntimeSettingsViewModelBinding>?
|
||||||
|
settingsLease = null;
|
||||||
|
if (host.InputDispatcher is not null
|
||||||
|
&& optional.KeyBindingTarget is not null)
|
||||||
|
{
|
||||||
|
settingsLease = scope.Acquire(
|
||||||
|
"settings view model binding",
|
||||||
|
() => _dependencies.Settings.CreateViewModelBinding(
|
||||||
|
_dependencies.KeyBindings,
|
||||||
|
host.InputDispatcher,
|
||||||
|
optional.KeyBindingTarget.Apply),
|
||||||
|
static value => value.Dispose());
|
||||||
|
settingsBinding = settingsLease.Resource;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.SettingsViewModelBound);
|
||||||
|
settingsPanel = new SettingsPanel(settingsBinding.ViewModel);
|
||||||
|
panelHost.Register(settingsPanel);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.SettingsRegistered);
|
||||||
|
}
|
||||||
|
|
||||||
|
var backendLease = scope.Acquire(
|
||||||
|
"developer frame backend",
|
||||||
|
() => _factory.CreateBackend(bootstrap, panelHost),
|
||||||
|
static value => value.Dispose());
|
||||||
|
IDevToolsFrameBackend backend = backendLease.Resource;
|
||||||
|
bootstrapLease.Transfer();
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.BackendCreated);
|
||||||
|
|
||||||
|
var cameraMenu = new DevToolsCameraMenuOperations(
|
||||||
|
host.CameraController,
|
||||||
|
optional.PlayerModeCommands);
|
||||||
|
var commandBus = new DevToolsCommandBusSource();
|
||||||
|
var presenter = new DevToolsFramePresenter(
|
||||||
|
backend,
|
||||||
|
cameraMenu,
|
||||||
|
commandBus,
|
||||||
|
_dependencies.FrameProfiler,
|
||||||
|
new DevToolsPanelSet(
|
||||||
|
vitalsPanel,
|
||||||
|
chatPanel,
|
||||||
|
debugPanel,
|
||||||
|
debugVm,
|
||||||
|
settingsPanel));
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.PresenterCreated);
|
||||||
|
|
||||||
|
var framebufferLease = scope.Acquire(
|
||||||
|
"developer framebuffer binding",
|
||||||
|
() => new FramebufferDevToolsBinding(
|
||||||
|
_dependencies.FramebufferResize,
|
||||||
|
new DevToolsFramebufferTarget(presenter)),
|
||||||
|
static value => value.Dispose());
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.FramebufferBound);
|
||||||
|
|
||||||
|
presenter.ResetLayout(
|
||||||
|
_dependencies.Window.Size.X,
|
||||||
|
_dependencies.Window.Size.Y,
|
||||||
|
DevToolsPanelLayoutCondition.FirstUseEver);
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.InitialLayoutApplied);
|
||||||
|
|
||||||
|
var ownerLease = scope.Acquire(
|
||||||
|
"developer composition owner",
|
||||||
|
() => new DevToolsCompositionOwner(
|
||||||
|
input,
|
||||||
|
backend,
|
||||||
|
presenter,
|
||||||
|
commandBus,
|
||||||
|
vitals,
|
||||||
|
chatVm,
|
||||||
|
debugVm,
|
||||||
|
optional,
|
||||||
|
settingsBinding,
|
||||||
|
_dependencies.CombatFeedback,
|
||||||
|
framebufferLease.Resource),
|
||||||
|
static value => value.Dispose());
|
||||||
|
DevToolsCompositionOwner owner = ownerLease.Resource;
|
||||||
|
|
||||||
|
inputLease.Transfer();
|
||||||
|
owner.AdoptInput();
|
||||||
|
backendLease.Transfer();
|
||||||
|
owner.AdoptBackend();
|
||||||
|
chatLease.Transfer();
|
||||||
|
owner.AdoptChat();
|
||||||
|
debugLease.Transfer();
|
||||||
|
owner.AdoptDebug();
|
||||||
|
combatBindingLease.Transfer();
|
||||||
|
owner.AdoptCombatBinding();
|
||||||
|
if (settingsLease is not null)
|
||||||
|
{
|
||||||
|
settingsLease.Transfer();
|
||||||
|
owner.AdoptSettings();
|
||||||
|
}
|
||||||
|
framebufferLease.Transfer();
|
||||||
|
owner.AdoptFramebuffer();
|
||||||
|
owner.CompleteOwnership();
|
||||||
|
|
||||||
|
owner = ownerLease.Publish(
|
||||||
|
_publication.PublishDevTools);
|
||||||
|
published = true;
|
||||||
|
Fault(SettingsDevToolsCompositionPoint.DevToolsPublished);
|
||||||
|
scope.Complete();
|
||||||
|
_dependencies.Log(
|
||||||
|
"devtools: ImGui panel host ready " +
|
||||||
|
"(VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
if (published)
|
||||||
|
throw;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scope.RollbackAndThrow(failure);
|
||||||
|
}
|
||||||
|
catch (Exception rolledBack) when (!HasIncompleteCleanup(rolledBack))
|
||||||
|
{
|
||||||
|
_dependencies.Log(
|
||||||
|
$"devtools: ImGui init failed: {rolledBack.Message} — devtools disabled");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new System.Diagnostics.UnreachableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DebugVM CreateDebugViewModel(IDevToolsRuntimeFacts facts)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(facts);
|
||||||
|
return new DebugVM(
|
||||||
|
() => facts.PlayerPosition,
|
||||||
|
() => facts.PlayerHeadingDegrees,
|
||||||
|
() => facts.PlayerCellId,
|
||||||
|
() => facts.PlayerOnGround,
|
||||||
|
() => facts.InPlayerMode,
|
||||||
|
() => facts.InFlyMode,
|
||||||
|
() => facts.VerticalVelocity,
|
||||||
|
() => facts.EntityCount,
|
||||||
|
() => facts.AnimatedCount,
|
||||||
|
() => facts.VisibleLandblocks,
|
||||||
|
() => facts.TotalLandblocks,
|
||||||
|
() => facts.ShadowObjectCount,
|
||||||
|
() => facts.NearestObjectDistance,
|
||||||
|
() => facts.NearestObjectLabel,
|
||||||
|
() => facts.Colliding,
|
||||||
|
() => facts.CollisionWireframesVisible,
|
||||||
|
() => facts.StreamingRadius,
|
||||||
|
() => facts.MouseSensitivity,
|
||||||
|
() => facts.ChaseDistance,
|
||||||
|
() => facts.RmbOrbitHeld,
|
||||||
|
() => facts.HourName,
|
||||||
|
() => facts.DayFraction,
|
||||||
|
() => facts.Weather,
|
||||||
|
() => facts.ActiveLights,
|
||||||
|
() => facts.RegisteredLights,
|
||||||
|
() => facts.ParticleCount,
|
||||||
|
() => facts.Fps,
|
||||||
|
() => facts.FrameMilliseconds,
|
||||||
|
_dependencies.Combat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasIncompleteCleanup(Exception failure)
|
||||||
|
{
|
||||||
|
if (failure is ImGuiBootstrapperConstructionException)
|
||||||
|
return true;
|
||||||
|
if (failure is IRetryableResourceCleanup cleanup
|
||||||
|
&& !cleanup.IsCleanupComplete)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (failure is AggregateException aggregate)
|
||||||
|
return aggregate.InnerExceptions.Any(HasIncompleteCleanup);
|
||||||
|
return failure.InnerException is { } inner && HasIncompleteCleanup(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fault(SettingsDevToolsCompositionPoint point) =>
|
||||||
|
_faultInjection?.Invoke(point);
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,9 @@ namespace AcDream.App.Input;
|
||||||
/// chase-camera lifetimes. The window host wires this owner but never mutates
|
/// chase-camera lifetimes. The window host wires this owner but never mutates
|
||||||
/// those slots directly.
|
/// those slots directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations
|
internal sealed class PlayerModeController :
|
||||||
|
ILocalPlayerTeleportModeOperations,
|
||||||
|
IDevToolsPlayerModeTarget
|
||||||
{
|
{
|
||||||
private readonly LocalPlayerModeState _mode;
|
private readonly LocalPlayerModeState _mode;
|
||||||
private readonly LocalPlayerControllerSlot _controllerSlot;
|
private readonly LocalPlayerControllerSlot _controllerSlot;
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,20 @@ using Silk.NET.Input;
|
||||||
|
|
||||||
namespace AcDream.App.Input;
|
namespace AcDream.App.Input;
|
||||||
|
|
||||||
|
internal interface IDevToolsInputContext : IInputContext, IDisposable
|
||||||
|
{
|
||||||
|
bool IsDisposalComplete { get; }
|
||||||
|
void Activate();
|
||||||
|
void Deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-owning input-context view for third-party frontends such as Silk's
|
/// Non-owning input-context view for third-party frontends such as Silk's
|
||||||
/// ImGuiController. It interposes named device relays so frontend callbacks can
|
/// ImGuiController. It interposes named device relays so frontend callbacks can
|
||||||
/// be silenced before a long live-session close and physically detached later,
|
/// be silenced before a long live-session close and physically detached later,
|
||||||
/// without disposing the canonical Silk input context.
|
/// without disposing the canonical Silk input context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class QuiescentInputContext : IInputContext
|
internal sealed class QuiescentInputContext : IDevToolsInputContext
|
||||||
{
|
{
|
||||||
private readonly IInputContext _inner;
|
private readonly IInputContext _inner;
|
||||||
private readonly HostQuiescenceGate _quiescence;
|
private readonly HostQuiescenceGate _quiescence;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
|
||||||
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
|
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal interface IDevToolsFrameBackend
|
internal interface IDevToolsFrameBackend : IDisposable
|
||||||
{
|
{
|
||||||
void BeginFrame(float deltaSeconds);
|
void BeginFrame(float deltaSeconds);
|
||||||
|
|
||||||
|
|
@ -93,12 +93,12 @@ internal interface IDevToolsPanelSet
|
||||||
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
|
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
|
||||||
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
|
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ImGuiBootstrapper _bootstrap;
|
private readonly IImGuiBootstrapper _bootstrap;
|
||||||
private readonly ImGuiPanelHost _panels;
|
private readonly ImGuiPanelHost _panels;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public ImGuiDevToolsFrameBackend(
|
public ImGuiDevToolsFrameBackend(
|
||||||
ImGuiBootstrapper bootstrap,
|
IImGuiBootstrapper bootstrap,
|
||||||
ImGuiPanelHost panels)
|
ImGuiPanelHost panels)
|
||||||
{
|
{
|
||||||
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
|
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
|
||||||
|
|
@ -157,11 +157,14 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDispos
|
||||||
internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
|
internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
|
||||||
{
|
{
|
||||||
private readonly CameraController _camera;
|
private readonly CameraController _camera;
|
||||||
private PlayerModeController? _playerMode;
|
private readonly IDevToolsPlayerModeCommands _playerMode;
|
||||||
|
|
||||||
public DevToolsCameraMenuOperations(CameraController camera)
|
public DevToolsCameraMenuOperations(
|
||||||
|
CameraController camera,
|
||||||
|
IDevToolsPlayerModeCommands playerMode)
|
||||||
{
|
{
|
||||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||||
|
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsFlyMode => _camera.IsFlyMode;
|
public bool IsFlyMode => _camera.IsFlyMode;
|
||||||
|
|
@ -172,25 +175,41 @@ internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperatio
|
||||||
set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
|
set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Bind(PlayerModeController playerMode)
|
public void ToggleFlyOrChase() => _playerMode.ToggleFlyOrChase();
|
||||||
{
|
|
||||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ToggleFlyOrChase() => _playerMode?.ToggleFlyOrChase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
|
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
|
||||||
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
|
||||||
{
|
{
|
||||||
private LiveSessionController? _session;
|
private LiveSessionController? _session;
|
||||||
|
private bool _deactivated;
|
||||||
|
|
||||||
public ICommandBus Current =>
|
public ICommandBus Current =>
|
||||||
_session?.Commands ?? NullCommandBus.Instance;
|
!_deactivated && _session is { } session
|
||||||
|
? session.Commands
|
||||||
|
: NullCommandBus.Instance;
|
||||||
|
|
||||||
public void Bind(LiveSessionController session)
|
public void Bind(LiveSessionController session)
|
||||||
{
|
{
|
||||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||||
|
if (_session is not null && !ReferenceEquals(_session, session))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Developer command-bus authority is already bound.");
|
||||||
|
_session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(LiveSessionController session)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
if (ReferenceEquals(_session, session))
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
_deactivated = true;
|
||||||
|
_session = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
289
src/AcDream.App/Rendering/DevToolsRuntimeSources.cs
Normal file
289
src/AcDream.App/Rendering/DevToolsRuntimeSources.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Physics;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Lighting;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Vfx;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
internal interface IRenderFrameDiagnosticsSnapshotSource
|
||||||
|
{
|
||||||
|
RenderFrameDiagnosticsSnapshot Snapshot { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase-3 view over the Phase-6 diagnostics owner. Before that owner exists,
|
||||||
|
/// consumers see the same 60 Hz seed used by the former null-safe closures.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DeferredRenderFrameDiagnosticsSource
|
||||||
|
: IRenderFrameDiagnosticsSnapshotSource
|
||||||
|
{
|
||||||
|
private IRenderFrameDiagnosticsSnapshotSource? _target;
|
||||||
|
private bool _deactivated;
|
||||||
|
|
||||||
|
public RenderFrameDiagnosticsSnapshot Snapshot =>
|
||||||
|
!_deactivated && _target is { } target
|
||||||
|
? target.Snapshot
|
||||||
|
: RenderFrameDiagnosticsSnapshot.Initial;
|
||||||
|
|
||||||
|
public void Bind(IRenderFrameDiagnosticsSnapshotSource target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||||
|
if (_target is not null && !ReferenceEquals(_target, target))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Render-frame diagnostics are already bound.");
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(IRenderFrameDiagnosticsSnapshotSource target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
if (ReferenceEquals(_target, target))
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
_deactivated = true;
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ICanonicalWorldEntityCountSource
|
||||||
|
{
|
||||||
|
int EntityCount { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prevents early developer UI from retaining the empty bootstrap
|
||||||
|
/// <see cref="GpuWorldState"/> that is replaced during Phase 6.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DeferredCanonicalWorldEntityCountSource
|
||||||
|
: ICanonicalWorldEntityCountSource
|
||||||
|
{
|
||||||
|
private GpuWorldState? _target;
|
||||||
|
private bool _deactivated;
|
||||||
|
|
||||||
|
public int EntityCount => !_deactivated ? _target?.Entities.Count ?? 0 : 0;
|
||||||
|
|
||||||
|
public void Bind(GpuWorldState target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||||
|
if (_target is not null && !ReferenceEquals(_target, target))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The canonical world entity-count source is already bound.");
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(GpuWorldState target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
if (ReferenceEquals(_target, target))
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
_deactivated = true;
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IDevToolsPlayerModeCommands
|
||||||
|
{
|
||||||
|
void ToggleFlyOrChase();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IDevToolsPlayerModeTarget
|
||||||
|
{
|
||||||
|
void ToggleFlyOrChase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase-3 command edge resolved by the Phase-7 player owner.</summary>
|
||||||
|
internal sealed class DeferredDevToolsPlayerModeCommands
|
||||||
|
: IDevToolsPlayerModeCommands
|
||||||
|
{
|
||||||
|
private IDevToolsPlayerModeTarget? _target;
|
||||||
|
private bool _deactivated;
|
||||||
|
|
||||||
|
public void Bind(IDevToolsPlayerModeTarget target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
ObjectDisposedException.ThrowIf(_deactivated, this);
|
||||||
|
if (_target is not null && !ReferenceEquals(_target, target))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Developer player-mode commands are already bound.");
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind(IDevToolsPlayerModeTarget target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
if (ReferenceEquals(_target, target))
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Deactivate()
|
||||||
|
{
|
||||||
|
_deactivated = true;
|
||||||
|
_target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleFlyOrChase()
|
||||||
|
{
|
||||||
|
if (!_deactivated)
|
||||||
|
_target?.ToggleFlyOrChase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IDevToolsRuntimeFacts
|
||||||
|
{
|
||||||
|
Vector3 PlayerPosition { get; }
|
||||||
|
float PlayerHeadingDegrees { get; }
|
||||||
|
uint PlayerCellId { get; }
|
||||||
|
bool PlayerOnGround { get; }
|
||||||
|
bool InPlayerMode { get; }
|
||||||
|
bool InFlyMode { get; }
|
||||||
|
float VerticalVelocity { get; }
|
||||||
|
int EntityCount { get; }
|
||||||
|
int AnimatedCount { get; }
|
||||||
|
int VisibleLandblocks { get; }
|
||||||
|
int TotalLandblocks { get; }
|
||||||
|
int ShadowObjectCount { get; }
|
||||||
|
float NearestObjectDistance { get; }
|
||||||
|
string NearestObjectLabel { get; }
|
||||||
|
bool Colliding { get; }
|
||||||
|
bool CollisionWireframesVisible { get; }
|
||||||
|
int StreamingRadius { get; }
|
||||||
|
float MouseSensitivity { get; }
|
||||||
|
float ChaseDistance { get; }
|
||||||
|
bool RmbOrbitHeld { get; }
|
||||||
|
string HourName { get; }
|
||||||
|
float DayFraction { get; }
|
||||||
|
string Weather { get; }
|
||||||
|
int ActiveLights { get; }
|
||||||
|
int RegisteredLights { get; }
|
||||||
|
int ParticleCount { get; }
|
||||||
|
float Fps { get; }
|
||||||
|
float FrameMilliseconds { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only developer presentation model assembled from focused canonical
|
||||||
|
/// owners. It caches no world, player, frame, or environment values.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DevToolsRuntimeFacts : IDevToolsRuntimeFacts
|
||||||
|
{
|
||||||
|
private readonly ILocalPlayerModeSource _mode;
|
||||||
|
private readonly ILocalPlayerControllerSource _player;
|
||||||
|
private readonly CameraController _camera;
|
||||||
|
private readonly ICanonicalWorldEntityCountSource _world;
|
||||||
|
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||||
|
private readonly IDebugVmRenderFactsSource _renderFacts;
|
||||||
|
private readonly PhysicsEngine _physics;
|
||||||
|
private readonly IWorldSceneDebugStateSource _sceneDebug;
|
||||||
|
private readonly IWorldRenderRangeSource _renderRange;
|
||||||
|
private readonly CameraPointerInputController? _pointer;
|
||||||
|
private readonly WorldEnvironmentController _environment;
|
||||||
|
private readonly LightManager _lights;
|
||||||
|
private readonly ParticleSystem _particles;
|
||||||
|
private readonly IRenderFrameDiagnosticsSnapshotSource _frame;
|
||||||
|
|
||||||
|
public DevToolsRuntimeFacts(
|
||||||
|
ILocalPlayerModeSource mode,
|
||||||
|
ILocalPlayerControllerSource player,
|
||||||
|
CameraController camera,
|
||||||
|
ICanonicalWorldEntityCountSource world,
|
||||||
|
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
|
||||||
|
IDebugVmRenderFactsSource renderFacts,
|
||||||
|
PhysicsEngine physics,
|
||||||
|
IWorldSceneDebugStateSource sceneDebug,
|
||||||
|
IWorldRenderRangeSource renderRange,
|
||||||
|
CameraPointerInputController? pointer,
|
||||||
|
WorldEnvironmentController environment,
|
||||||
|
LightManager lights,
|
||||||
|
ParticleSystem particles,
|
||||||
|
IRenderFrameDiagnosticsSnapshotSource frame)
|
||||||
|
{
|
||||||
|
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||||
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||||
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||||
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
|
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||||
|
_renderFacts = renderFacts ?? throw new ArgumentNullException(nameof(renderFacts));
|
||||||
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||||
|
_sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug));
|
||||||
|
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
||||||
|
_pointer = pointer;
|
||||||
|
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||||
|
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
|
||||||
|
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||||||
|
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 PlayerPosition
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_mode.IsPlayerMode && _player.Controller is { } player)
|
||||||
|
return player.Position;
|
||||||
|
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverse);
|
||||||
|
return new Vector3(inverse.M41, inverse.M42, inverse.M43);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public float PlayerHeadingDegrees
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
float degrees;
|
||||||
|
if (_mode.IsPlayerMode && _player.Controller is { } player)
|
||||||
|
{
|
||||||
|
degrees = player.Yaw * (180f / MathF.PI);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverse);
|
||||||
|
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
|
||||||
|
degrees = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
degrees %= 360f;
|
||||||
|
return degrees < 0f ? degrees + 360f : degrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint PlayerCellId =>
|
||||||
|
_mode.IsPlayerMode ? _player.Controller?.CellId ?? 0u : 0u;
|
||||||
|
public bool PlayerOnGround =>
|
||||||
|
_mode.IsPlayerMode && _player.Controller is { IsAirborne: false };
|
||||||
|
public bool InPlayerMode => _mode.IsPlayerMode;
|
||||||
|
public bool InFlyMode => _camera.IsFlyMode;
|
||||||
|
public float VerticalVelocity => _player.Controller?.VerticalVelocity ?? 0f;
|
||||||
|
public int EntityCount => _world.EntityCount;
|
||||||
|
public int AnimatedCount => _animations.Count;
|
||||||
|
public int VisibleLandblocks => _renderFacts.DebugVmFacts.VisibleLandblocks;
|
||||||
|
public int TotalLandblocks => _renderFacts.DebugVmFacts.TotalLandblocks;
|
||||||
|
public int ShadowObjectCount => _physics.ShadowObjects.TotalRegistered;
|
||||||
|
public float NearestObjectDistance => _renderFacts.DebugVmFacts.NearestObjectDistance;
|
||||||
|
public string NearestObjectLabel => _renderFacts.DebugVmFacts.NearestObjectLabel;
|
||||||
|
public bool Colliding => _renderFacts.DebugVmFacts.Colliding;
|
||||||
|
public bool CollisionWireframesVisible => _sceneDebug.CollisionWireframesVisible;
|
||||||
|
public int StreamingRadius => _renderRange.NearRadius;
|
||||||
|
public float MouseSensitivity => _pointer?.ActiveSensitivity ?? 1f;
|
||||||
|
public float ChaseDistance => _camera.Chase?.Distance ?? 0f;
|
||||||
|
public bool RmbOrbitHeld => _pointer?.RmbOrbitHeld ?? false;
|
||||||
|
public string HourName => _environment.WorldTime.CurrentCalendar.Hour.ToString();
|
||||||
|
public float DayFraction => (float)_environment.WorldTime.DayFraction;
|
||||||
|
public string Weather => _environment.Weather.Kind.ToString();
|
||||||
|
public int ActiveLights => _lights.ActiveCount;
|
||||||
|
public int RegisteredLights => _lights.RegisteredCount;
|
||||||
|
public int ParticleCount => _particles.ActiveParticleCount;
|
||||||
|
public float Fps => (float)_frame.Snapshot.Fps;
|
||||||
|
public float FrameMilliseconds => (float)_frame.Snapshot.FrameMilliseconds;
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,31 @@ internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter
|
||||||
DevToolsPanelLayoutCondition.Always);
|
DevToolsPanelLayoutCondition.Always);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Expected-owner lease for the optional Phase-3 resize edge.</summary>
|
||||||
|
internal sealed class FramebufferDevToolsBinding : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FramebufferResizeController _owner;
|
||||||
|
private readonly IFramebufferDevToolsTarget _target;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public FramebufferDevToolsBinding(
|
||||||
|
FramebufferResizeController owner,
|
||||||
|
IFramebufferDevToolsTarget target)
|
||||||
|
{
|
||||||
|
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||||
|
_target = target ?? throw new ArgumentNullException(nameof(target));
|
||||||
|
_owner.BindDevTools(_target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_owner.UnbindDevTools(_target);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
|
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
|
||||||
/// to come from Window.Size; this owner only applies physical framebuffer
|
/// to come from Window.Size; this owner only applies physical framebuffer
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ public sealed class GameWindow :
|
||||||
IDisposable,
|
IDisposable,
|
||||||
IGameWindowPlatformPublication<GL, IInputContext>,
|
IGameWindowPlatformPublication<GL, IInputContext>,
|
||||||
IGameWindowHostInputCameraPublication,
|
IGameWindowHostInputCameraPublication,
|
||||||
IGameWindowContentEffectsAudioPublication
|
IGameWindowContentEffectsAudioPublication,
|
||||||
|
IGameWindowSettingsDevToolsPublication
|
||||||
{
|
{
|
||||||
private static double ClientTimerNow() =>
|
private static double ClientTimerNow() =>
|
||||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||||
|
|
@ -33,7 +34,6 @@ public sealed class GameWindow :
|
||||||
private SilkWindowCallbackBinding? _windowCallbacks;
|
private SilkWindowCallbackBinding? _windowCallbacks;
|
||||||
private GL? _gl;
|
private GL? _gl;
|
||||||
private IInputContext? _input;
|
private IInputContext? _input;
|
||||||
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
|
|
||||||
private TerrainModernRenderer? _terrain;
|
private TerrainModernRenderer? _terrain;
|
||||||
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
|
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
|
||||||
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
|
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
|
||||||
|
|
@ -345,10 +345,9 @@ public sealed class GameWindow :
|
||||||
|
|
||||||
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
|
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
|
||||||
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
|
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
|
||||||
private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend;
|
|
||||||
private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
|
private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
|
||||||
private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu;
|
|
||||||
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
|
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
|
||||||
|
private DevToolsCompositionOwner? _devToolsComposition;
|
||||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||||
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
|
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
|
||||||
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||||
|
|
@ -434,7 +433,6 @@ public sealed class GameWindow :
|
||||||
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
|
||||||
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
|
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
|
||||||
private readonly FramebufferResizeController _framebufferResize;
|
private readonly FramebufferResizeController _framebufferResize;
|
||||||
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
|
|
||||||
private AcDream.App.Input.PlayerModeController? _playerModeController;
|
private AcDream.App.Input.PlayerModeController? _playerModeController;
|
||||||
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
|
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
|
||||||
_playerApproachCompletions = new();
|
_playerApproachCompletions = new();
|
||||||
|
|
@ -784,6 +782,26 @@ public sealed class GameWindow :
|
||||||
_audioSink = value.HookSink;
|
_audioSink = value.HookSink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void IGameWindowSettingsDevToolsPublication.PublishDevTools(
|
||||||
|
DevToolsCompositionOwner value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
if (_devToolsComposition is not null
|
||||||
|
|| _devToolsFramePresenter is not null
|
||||||
|
|| _devToolsCommandBus is not null
|
||||||
|
|| _debugVm is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The GameWindow composition shell already owns developer tools.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_devToolsComposition = value;
|
||||||
|
_devToolsFramePresenter = value.Presenter;
|
||||||
|
_devToolsCommandBus = value.CommandBus;
|
||||||
|
_vitalsVm = value.Vitals;
|
||||||
|
_debugVm = value.Debug;
|
||||||
|
}
|
||||||
|
|
||||||
private static void PublishCompositionOwner<T>(
|
private static void PublishCompositionOwner<T>(
|
||||||
ref T? destination,
|
ref T? destination,
|
||||||
T value,
|
T value,
|
||||||
|
|
@ -856,6 +874,64 @@ public sealed class GameWindow :
|
||||||
Console.Error.WriteLine),
|
Console.Error.WriteLine),
|
||||||
this).Compose(platform, hostInputCamera);
|
this).Compose(platform, hostInputCamera);
|
||||||
|
|
||||||
|
SettingsDevToolsOptionalDependencies? optionalDevTools = null;
|
||||||
|
if (DevToolsEnabled)
|
||||||
|
{
|
||||||
|
var devToolsWorldEntities =
|
||||||
|
new DeferredCanonicalWorldEntityCountSource();
|
||||||
|
var devToolsFrameDiagnostics =
|
||||||
|
new DeferredRenderFrameDiagnosticsSource();
|
||||||
|
var devToolsPlayerModeCommands =
|
||||||
|
new DeferredDevToolsPlayerModeCommands();
|
||||||
|
var devToolsFacts = new DevToolsRuntimeFacts(
|
||||||
|
_localPlayerMode,
|
||||||
|
_playerControllerSlot,
|
||||||
|
hostInputCamera.CameraController,
|
||||||
|
devToolsWorldEntities,
|
||||||
|
_animatedEntities,
|
||||||
|
_debugVmRenderFacts,
|
||||||
|
_physicsEngine,
|
||||||
|
_worldSceneDebugState,
|
||||||
|
_renderRange,
|
||||||
|
hostInputCamera.CameraPointerInput,
|
||||||
|
_worldEnvironment,
|
||||||
|
Lighting,
|
||||||
|
contentEffectsAudio.ParticleSystem,
|
||||||
|
devToolsFrameDiagnostics);
|
||||||
|
IRuntimeKeyBindingTarget? keyBindingTarget =
|
||||||
|
hostInputCamera.InputDispatcher is { } settingsDispatcher
|
||||||
|
? new RuntimeKeyBindingTarget(settingsDispatcher)
|
||||||
|
: null;
|
||||||
|
optionalDevTools = new SettingsDevToolsOptionalDependencies(
|
||||||
|
devToolsFacts,
|
||||||
|
keyBindingTarget,
|
||||||
|
devToolsWorldEntities,
|
||||||
|
devToolsFrameDiagnostics,
|
||||||
|
devToolsPlayerModeCommands);
|
||||||
|
}
|
||||||
|
SettingsDevToolsResult settingsDevTools =
|
||||||
|
new SettingsDevToolsCompositionPhase(
|
||||||
|
new SettingsDevToolsDependencies(
|
||||||
|
_window!,
|
||||||
|
_runtimeSettings,
|
||||||
|
new RuntimeSettingsStartupTargets(
|
||||||
|
new SilkRuntimeDisplayWindowTarget(_window!),
|
||||||
|
_displayFramePacing,
|
||||||
|
hostInputCamera.CameraController,
|
||||||
|
contentEffectsAudio.Audio?.Engine),
|
||||||
|
_hostQuiescence,
|
||||||
|
Chat,
|
||||||
|
Combat,
|
||||||
|
LocalPlayer,
|
||||||
|
optionalDevTools,
|
||||||
|
_runtimeDiagnosticCommands,
|
||||||
|
_combatFeedback,
|
||||||
|
_keyBindings,
|
||||||
|
_frameProfiler,
|
||||||
|
_framebufferResize,
|
||||||
|
Console.WriteLine),
|
||||||
|
this).Compose(platform, hostInputCamera, contentEffectsAudio);
|
||||||
|
|
||||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||||
_gl.Enable(EnableCap.DepthTest);
|
_gl.Enable(EnableCap.DepthTest);
|
||||||
|
|
||||||
|
|
@ -906,222 +982,6 @@ public sealed class GameWindow :
|
||||||
Console.WriteLine("world-hud font: no system monospace font found");
|
Console.WriteLine("world-hud font: no system monospace font found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are runtime state, not devtools state. Apply the immutable
|
|
||||||
// pre-window snapshot once after camera/audio exist and before any
|
|
||||||
// optional frontend or world/render factory consumes it.
|
|
||||||
_runtimeSettings.ApplyStartup(
|
|
||||||
new RuntimeSettingsStartupTargets(
|
|
||||||
new SilkRuntimeDisplayWindowTarget(_window!),
|
|
||||||
_displayFramePacing,
|
|
||||||
_cameraController!,
|
|
||||||
_audioEngine));
|
|
||||||
|
|
||||||
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
|
|
||||||
// isn't set: no context creation, no per-frame branches hit.
|
|
||||||
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
|
|
||||||
if (DevToolsEnabled)
|
|
||||||
{
|
|
||||||
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
|
|
||||||
AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
|
|
||||||
_input!,
|
|
||||||
_hostQuiescence);
|
|
||||||
imguiBootstrap =
|
|
||||||
new AcDream.UI.ImGui.ImGuiBootstrapper(
|
|
||||||
_gl!,
|
|
||||||
_window!,
|
|
||||||
_devToolsInputContext);
|
|
||||||
_devToolsInputContext.Activate();
|
|
||||||
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
|
|
||||||
|
|
||||||
// VitalsVM: GUID=0 at construction; set later at EnterWorld
|
|
||||||
// (see the _playerServerGuid assignment path). Pre-login the
|
|
||||||
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
|
|
||||||
// bars surface only after the first PlayerDescription has
|
|
||||||
// populated LocalPlayer (Issue #5).
|
|
||||||
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
|
||||||
var vitalsPanel =
|
|
||||||
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
|
|
||||||
panelHost.Register(vitalsPanel);
|
|
||||||
|
|
||||||
// ChatPanel: reads the tail of the shared ChatLog. No GUID
|
|
||||||
// dependency — works pre-login (empty) and post-login (live
|
|
||||||
// tail of received speech/tells/channels/system msgs).
|
|
||||||
// FpsProvider + PositionProvider plumb the runtime state
|
|
||||||
// the client-side /framerate and /loc commands need; the
|
|
||||||
// panel asks the VM, the VM asks GameWindow via these
|
|
||||||
// delegates, no panel-vs-renderer-vs-state coupling.
|
|
||||||
var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat)
|
|
||||||
{
|
|
||||||
FpsProvider = () =>
|
|
||||||
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
|
|
||||||
PositionProvider = () => GetDebugPlayerPosition(),
|
|
||||||
};
|
|
||||||
var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
|
|
||||||
panelHost.Register(chatPanel);
|
|
||||||
|
|
||||||
// Phase I.2: DebugPanel — replaces the deleted custom
|
|
||||||
// DebugOverlay (six floating panels + hint bar + toast).
|
|
||||||
// The VM closes over every data source the old snapshot
|
|
||||||
// record exposed; reads are live (no per-frame snapshot
|
|
||||||
// build). Action hooks tie the panel's cycle/toggle
|
|
||||||
// buttons back to the same routines the F2/F7/F10
|
|
||||||
// keybinds use.
|
|
||||||
_debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM(
|
|
||||||
getPlayerPosition: () => GetDebugPlayerPosition(),
|
|
||||||
getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(),
|
|
||||||
getPlayerCellId: () => GetDebugPlayerCellId(),
|
|
||||||
getPlayerOnGround: () => GetDebugPlayerOnGround(),
|
|
||||||
getInPlayerMode: () => _playerMode,
|
|
||||||
getInFlyMode: () => _cameraController?.IsFlyMode ?? false,
|
|
||||||
getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f,
|
|
||||||
getEntityCount: () => _worldState.Entities.Count,
|
|
||||||
getAnimatedCount: () => _animatedEntities.Count,
|
|
||||||
getLandblocksVisible: () =>
|
|
||||||
_debugVmRenderFacts.DebugVmFacts.VisibleLandblocks,
|
|
||||||
getLandblocksTotal: () =>
|
|
||||||
_debugVmRenderFacts.DebugVmFacts.TotalLandblocks,
|
|
||||||
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
|
|
||||||
getNearestObjDist: () =>
|
|
||||||
_debugVmRenderFacts.DebugVmFacts.NearestObjectDistance,
|
|
||||||
getNearestObjLabel: () =>
|
|
||||||
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
|
|
||||||
getColliding: () =>
|
|
||||||
_debugVmRenderFacts.DebugVmFacts.Colliding,
|
|
||||||
getDebugWireframes: () =>
|
|
||||||
_worldSceneDebugState.CollisionWireframesVisible,
|
|
||||||
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
|
|
||||||
|
|
||||||
getMouseSensitivity: () =>
|
|
||||||
_cameraPointerInput?.ActiveSensitivity ?? 1f,
|
|
||||||
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
|
|
||||||
getRmbOrbit: () =>
|
|
||||||
_cameraPointerInput?.RmbOrbitHeld ?? false,
|
|
||||||
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
|
|
||||||
getDayFraction: () => (float)WorldTime.DayFraction,
|
|
||||||
getWeather: () => Weather.Kind.ToString(),
|
|
||||||
getActiveLights: () => Lighting.ActiveCount,
|
|
||||||
getRegisteredLights: () => Lighting.RegisteredCount,
|
|
||||||
getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0,
|
|
||||||
getFps: () =>
|
|
||||||
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
|
|
||||||
getFrameMs: () =>
|
|
||||||
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
|
|
||||||
combat: Combat);
|
|
||||||
_debugVm.CycleTimeOfDay =
|
|
||||||
_runtimeDiagnosticCommands.CycleTimeOfDay;
|
|
||||||
_debugVm.CycleWeather =
|
|
||||||
_runtimeDiagnosticCommands.CycleWeather;
|
|
||||||
_debugVm.ToggleCollisionWires =
|
|
||||||
_runtimeDiagnosticCommands.ToggleCollisionWireframes;
|
|
||||||
// Phase K.2: free-fly toggle button — same routine the
|
|
||||||
// legacy F-key alias hits. Cancels the one-shot
|
|
||||||
// auto-entry if the user opts out of player mode before
|
|
||||||
// it fires, so the chase camera doesn't snap on top of
|
|
||||||
// the fly camera mid-inspection.
|
|
||||||
_debugVm.ToggleFlyMode = () =>
|
|
||||||
_playerModeController?.ToggleFlyOrChase();
|
|
||||||
_combatFeedback.ViewModel = _debugVm;
|
|
||||||
var debugPanel =
|
|
||||||
new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
|
|
||||||
panelHost.Register(debugPanel);
|
|
||||||
|
|
||||||
// Phase K.3 — Settings panel. SettingsVM owns a draft
|
|
||||||
// copy of the active KeyBindings. Save replaces the
|
|
||||||
// dispatcher's live table + writes JSON; Cancel reverts
|
|
||||||
// the draft. Construction is null-safe vs. the
|
|
||||||
// dispatcher because the dispatcher is built earlier in
|
|
||||||
// the same OnLoad path (see _inputDispatcher field).
|
|
||||||
AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null;
|
|
||||||
if (_inputDispatcher is not null)
|
|
||||||
{
|
|
||||||
settingsVm = _runtimeSettings.CreateViewModel(
|
|
||||||
_keyBindings,
|
|
||||||
_inputDispatcher,
|
|
||||||
bindings =>
|
|
||||||
{
|
|
||||||
_inputDispatcher.SetBindings(bindings);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
bindings.SaveToFile(
|
|
||||||
AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
|
|
||||||
Console.WriteLine(
|
|
||||||
"keybinds: saved to "
|
|
||||||
+ AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"keybinds: save failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
settingsPanel =
|
|
||||||
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm);
|
|
||||||
panelHost.Register(settingsPanel);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
|
|
||||||
|
|
||||||
// L.0 Display tab: seed sensible default positions for
|
|
||||||
// every registered panel. cond=FirstUseEver means imgui.ini
|
|
||||||
// takes precedence on subsequent launches — the user's
|
|
||||||
// dragged positions persist. Without this, the first-run
|
|
||||||
// experience stacks every panel at (0,0) which looks
|
|
||||||
// broken.
|
|
||||||
_devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend(
|
|
||||||
imguiBootstrap,
|
|
||||||
panelHost);
|
|
||||||
_devToolsCameraMenu =
|
|
||||||
new AcDream.App.Rendering.DevToolsCameraMenuOperations(
|
|
||||||
_cameraController!);
|
|
||||||
_devToolsCommandBus =
|
|
||||||
new AcDream.App.Rendering.DevToolsCommandBusSource();
|
|
||||||
_devToolsFramePresenter =
|
|
||||||
new AcDream.App.Rendering.DevToolsFramePresenter(
|
|
||||||
_devToolsBackend,
|
|
||||||
_devToolsCameraMenu,
|
|
||||||
_devToolsCommandBus,
|
|
||||||
_frameProfiler,
|
|
||||||
new AcDream.App.Rendering.DevToolsPanelSet(
|
|
||||||
vitalsPanel,
|
|
||||||
chatPanel,
|
|
||||||
debugPanel,
|
|
||||||
_debugVm,
|
|
||||||
settingsPanel));
|
|
||||||
_framebufferDevToolsTarget = new DevToolsFramebufferTarget(
|
|
||||||
_devToolsFramePresenter);
|
|
||||||
_framebufferResize.BindDevTools(_framebufferDevToolsTarget);
|
|
||||||
_devToolsFramePresenter.ResetLayout(
|
|
||||||
_window!.Size.X,
|
|
||||||
_window.Size.Y,
|
|
||||||
AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
|
|
||||||
// The focused backend borrows the bootstrap during normal
|
|
||||||
// operation. Construction failure still owns this local and
|
|
||||||
// must release it before abandoning the optional frontend.
|
|
||||||
imguiBootstrap?.Dispose();
|
|
||||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
|
||||||
{
|
|
||||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
|
||||||
_framebufferDevToolsTarget = null;
|
|
||||||
}
|
|
||||||
_devToolsInputContext?.Dispose();
|
|
||||||
_devToolsInputContext = null;
|
|
||||||
_devToolsBackend = null;
|
|
||||||
_devToolsFramePresenter = null;
|
|
||||||
_devToolsCameraMenu = null;
|
|
||||||
_devToolsCommandBus = null;
|
|
||||||
_vitalsVm = null;
|
|
||||||
_combatFeedback.ViewModel = null;
|
|
||||||
_debugVm = null;
|
|
||||||
_runtimeSettings.UnbindViewModel(settingsVm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint centerLandblockId = 0xA9B4FFFFu;
|
uint centerLandblockId = 0xA9B4FFFFu;
|
||||||
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
||||||
|
|
||||||
|
|
@ -1755,6 +1615,7 @@ public sealed class GameWindow :
|
||||||
wbSpawnAdapter,
|
wbSpawnAdapter,
|
||||||
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
|
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
|
||||||
entityScriptActivator: entityScriptActivator);
|
entityScriptActivator: entityScriptActivator);
|
||||||
|
_devToolsComposition?.LateBindings.WorldEntities.Bind(_worldState);
|
||||||
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
|
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
|
||||||
_worldState,
|
_worldState,
|
||||||
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
|
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
|
||||||
|
|
@ -2117,6 +1978,8 @@ public sealed class GameWindow :
|
||||||
_renderDiagnosticLog,
|
_renderDiagnosticLog,
|
||||||
_options.UiProbeDump,
|
_options.UiProbeDump,
|
||||||
resourceDiagnostics);
|
resourceDiagnostics);
|
||||||
|
_devToolsComposition?.LateBindings.FrameDiagnostics.Bind(
|
||||||
|
_renderFrameDiagnostics);
|
||||||
|
|
||||||
// Apply radii from the same immutable quality snapshot used for the
|
// Apply radii from the same immutable quality snapshot used for the
|
||||||
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
|
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
|
||||||
|
|
@ -2498,7 +2361,8 @@ public sealed class GameWindow :
|
||||||
_movementTruthDiagnostics,
|
_movementTruthDiagnostics,
|
||||||
_localPlayerSkills,
|
_localPlayerSkills,
|
||||||
_viewportAspect);
|
_viewportAspect);
|
||||||
_devToolsCameraMenu?.Bind(_playerModeController);
|
_devToolsComposition?.LateBindings.PlayerModeCommands.Bind(
|
||||||
|
_playerModeController);
|
||||||
_devToolsCommandBus?.Bind(_liveSessionController);
|
_devToolsCommandBus?.Bind(_liveSessionController);
|
||||||
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
|
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
|
||||||
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
|
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
|
||||||
|
|
@ -3216,59 +3080,6 @@ public sealed class GameWindow :
|
||||||
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
|
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
|
||||||
// always played (w6-cutover-map.md R3).
|
// always played (w6-cutover-map.md R3).
|
||||||
|
|
||||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
|
||||||
//
|
|
||||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
|
||||||
// GameWindow for live state on every frame. The helper methods below
|
|
||||||
// are the *named* targets of those closures (and of the F-key
|
|
||||||
// shortcuts that share the same actions). Keeping them as methods
|
|
||||||
// (vs ad-hoc lambdas where the VM is constructed) means both the
|
|
||||||
// panel button and the keybind run the *same* code, so behavior
|
|
||||||
// can't drift between the two surfaces.
|
|
||||||
|
|
||||||
/// <summary>Player-mode-aware position source for the DebugPanel.</summary>
|
|
||||||
private System.Numerics.Vector3 GetDebugPlayerPosition()
|
|
||||||
{
|
|
||||||
if (_playerMode && _playerController is not null)
|
|
||||||
return _playerController.Position;
|
|
||||||
if (_cameraController?.Active is { } cam)
|
|
||||||
{
|
|
||||||
// Camera world position from inverse of view matrix — same
|
|
||||||
// computation used by the scene-lighting UBO each frame.
|
|
||||||
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
|
|
||||||
return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
|
|
||||||
}
|
|
||||||
return System.Numerics.Vector3.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.</summary>
|
|
||||||
private float GetDebugPlayerHeadingDeg()
|
|
||||||
{
|
|
||||||
float deg;
|
|
||||||
if (_playerMode && _playerController is not null)
|
|
||||||
{
|
|
||||||
deg = _playerController.Yaw * (180f / MathF.PI);
|
|
||||||
}
|
|
||||||
else if (_cameraController?.Active is { } cam)
|
|
||||||
{
|
|
||||||
// Camera-relative heading from view matrix forward vector. Use
|
|
||||||
// the same -invView.Mxx convention the snapshot block used.
|
|
||||||
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
|
|
||||||
var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33);
|
|
||||||
deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return 0f;
|
|
||||||
}
|
|
||||||
deg %= 360f;
|
|
||||||
if (deg < 0f) deg += 360f;
|
|
||||||
return deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private uint GetDebugPlayerCellId() =>
|
|
||||||
_playerMode && _playerController is not null ? _playerController.CellId : 0u;
|
|
||||||
|
|
||||||
private bool GetDebugPlayerOnGround() =>
|
private bool GetDebugPlayerOnGround() =>
|
||||||
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
_playerMode && _playerController is not null && !_playerController.IsAirborne;
|
||||||
|
|
||||||
|
|
@ -3419,7 +3230,7 @@ public sealed class GameWindow :
|
||||||
new("mouse source", () => _mouseSource?.Deactivate()),
|
new("mouse source", () => _mouseSource?.Deactivate()),
|
||||||
new("keyboard source", () => _kbSource?.Deactivate()),
|
new("keyboard source", () => _kbSource?.Deactivate()),
|
||||||
new("retained UI input", _retailUiLease.QuiesceInput),
|
new("retained UI input", _retailUiLease.QuiesceInput),
|
||||||
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
|
new("developer tools input", () => _devToolsComposition?.DeactivateInput()),
|
||||||
]),
|
]),
|
||||||
// Live-session reset retires the complete streaming window. Every
|
// Live-session reset retires the complete streaming window. Every
|
||||||
// reset callback owner, especially LandblockStreamer, must remain live
|
// reset callback owner, especially LandblockStreamer, must remain live
|
||||||
|
|
@ -3473,7 +3284,7 @@ public sealed class GameWindow :
|
||||||
_gameplayInputActions = null;
|
_gameplayInputActions = null;
|
||||||
}),
|
}),
|
||||||
new("retained UI input", _retailUiLease.DeactivateInput),
|
new("retained UI input", _retailUiLease.DeactivateInput),
|
||||||
new("developer tools input", () => _devToolsInputContext?.Dispose()),
|
new("developer tools input", () => _devToolsComposition?.DetachInput()),
|
||||||
new("camera pointer", () =>
|
new("camera pointer", () =>
|
||||||
{
|
{
|
||||||
AcDream.App.Input.CameraPointerInputController? pointer =
|
AcDream.App.Input.CameraPointerInputController? pointer =
|
||||||
|
|
@ -3650,17 +3461,17 @@ public sealed class GameWindow :
|
||||||
[
|
[
|
||||||
new("developer tools", () =>
|
new("developer tools", () =>
|
||||||
{
|
{
|
||||||
if (_framebufferDevToolsTarget is { } framebufferTarget)
|
DevToolsCompositionOwner? owner = _devToolsComposition;
|
||||||
{
|
if (owner is null)
|
||||||
_framebufferResize.UnbindDevTools(framebufferTarget);
|
return;
|
||||||
_framebufferDevToolsTarget = null;
|
owner.DisposeFrontend();
|
||||||
}
|
if (!owner.IsDisposalComplete)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Developer-tools cleanup remains incomplete.");
|
||||||
|
_devToolsComposition = null;
|
||||||
_devToolsFramePresenter = null;
|
_devToolsFramePresenter = null;
|
||||||
_devToolsCameraMenu = null;
|
|
||||||
_devToolsCommandBus = null;
|
_devToolsCommandBus = null;
|
||||||
_devToolsBackend?.Dispose();
|
_debugVm = null;
|
||||||
_devToolsBackend = null;
|
|
||||||
_devToolsInputContext = null;
|
|
||||||
}),
|
}),
|
||||||
new("portal tunnel", () =>
|
new("portal tunnel", () =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,9 @@ internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
|
||||||
/// Publishes post-presentation frame statistics. The caller invokes this only after
|
/// Publishes post-presentation frame statistics. The caller invokes this only after
|
||||||
/// screenshot capture, preserving the accepted screenshot-before-title/resource order.
|
/// screenshot capture, preserving the accepted screenshot-before-title/resource order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RenderFrameDiagnosticsController : IRenderFrameDiagnosticsPhase
|
internal sealed class RenderFrameDiagnosticsController :
|
||||||
|
IRenderFrameDiagnosticsPhase,
|
||||||
|
IRenderFrameDiagnosticsSnapshotSource
|
||||||
{
|
{
|
||||||
internal const double PublicationIntervalSeconds = 0.5;
|
internal const double PublicationIntervalSeconds = 0.5;
|
||||||
|
|
||||||
|
|
|
||||||
45
src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs
Normal file
45
src/AcDream.App/Settings/RuntimeKeyBindingTarget.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
|
||||||
|
namespace AcDream.App.Settings;
|
||||||
|
|
||||||
|
internal interface IRuntimeKeyBindingTarget
|
||||||
|
{
|
||||||
|
void Apply(KeyBindings bindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a settings-panel keymap to the live dispatcher and then persists
|
||||||
|
/// that exact map. Persistence failure is reported without rolling back the
|
||||||
|
/// already accepted live binding, matching the former startup-root callback.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RuntimeKeyBindingTarget : IRuntimeKeyBindingTarget
|
||||||
|
{
|
||||||
|
private readonly InputDispatcher _dispatcher;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
|
||||||
|
public RuntimeKeyBindingTarget(
|
||||||
|
InputDispatcher dispatcher,
|
||||||
|
string? path = null,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
_path = path ?? KeyBindings.DefaultPath();
|
||||||
|
_log = log ?? Console.WriteLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(KeyBindings bindings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings);
|
||||||
|
_dispatcher.SetBindings(bindings);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bindings.SaveToFile(_path);
|
||||||
|
_log($"keybinds: saved to {_path}");
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
_log($"keybinds: save failed: {failure.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,34 @@ internal sealed record RuntimeSettingsSnapshot(
|
||||||
CharacterSettings Character,
|
CharacterSettings Character,
|
||||||
QualitySettings Quality);
|
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
|
internal interface IRuntimeSettingsStartupTarget
|
||||||
{
|
{
|
||||||
void ApplyDisplay(DisplaySettings display);
|
void ApplyDisplay(DisplaySettings display);
|
||||||
|
|
@ -248,6 +276,14 @@ internal sealed class RuntimeSettingsController :
|
||||||
return _viewModel;
|
return _viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
|
||||||
|
KeyBindings persistedBindings,
|
||||||
|
InputDispatcher dispatcher,
|
||||||
|
Action<KeyBindings> saveBindings) =>
|
||||||
|
new(
|
||||||
|
this,
|
||||||
|
CreateViewModel(persistedBindings, dispatcher, saveBindings));
|
||||||
|
|
||||||
public void UnbindViewModel(SettingsVM? expected = null)
|
public void UnbindViewModel(SettingsVM? expected = null)
|
||||||
{
|
{
|
||||||
if (expected is null || ReferenceEquals(_viewModel, expected))
|
if (expected is null || ReferenceEquals(_viewModel, expected))
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,14 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
/// unchanged transcript does not require a queue snapshot each frame.
|
/// unchanged transcript does not require a queue snapshot each frame.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatVM
|
public sealed class ChatVM : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Default number of tail entries rendered.</summary>
|
/// <summary>Default number of tail entries rendered.</summary>
|
||||||
public const int DefaultDisplayLimit = 20;
|
public const int DefaultDisplayLimit = 20;
|
||||||
|
|
||||||
private readonly ChatLog _log;
|
private readonly ChatLog _log;
|
||||||
private readonly int _displayLimit;
|
private readonly int _displayLimit;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sender name of the most recent INCOMING Tell. Drives the
|
/// Sender name of the most recent INCOMING Tell. Drives the
|
||||||
|
|
@ -104,6 +105,14 @@ public sealed class ChatVM
|
||||||
LastOutgoingTellTarget = entry.Sender;
|
LastOutgoingTellTarget = entry.Sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_log.EntryAppended -= OnEntryAppended;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Append a client-side system line to the chat log. Used by
|
/// Append a client-side system line to the chat log. Used by
|
||||||
/// client-handled commands (/help, /clear, future) to surface
|
/// client-handled commands (/help, /clear, future) to surface
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ public readonly record struct ToastMessage(
|
||||||
/// debug panels appear.
|
/// debug panels appear.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DebugVM
|
public sealed class DebugVM : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Maximum number of combat-event lines kept in the ring.</summary>
|
/// <summary>Maximum number of combat-event lines kept in the ring.</summary>
|
||||||
public const int MaxCombatEvents = 25;
|
public const int MaxCombatEvents = 25;
|
||||||
|
|
@ -103,6 +103,8 @@ public sealed class DebugVM
|
||||||
private readonly Func<int> _getParticleCount;
|
private readonly Func<int> _getParticleCount;
|
||||||
private readonly Func<float> _getFps;
|
private readonly Func<float> _getFps;
|
||||||
private readonly Func<float> _getFrameMs;
|
private readonly Func<float> _getFrameMs;
|
||||||
|
private readonly CombatState _combat;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private readonly Queue<CombatEventLine> _combatEvents = new();
|
private readonly Queue<CombatEventLine> _combatEvents = new();
|
||||||
private readonly Queue<ToastMessage> _toasts = new();
|
private readonly Queue<ToastMessage> _toasts = new();
|
||||||
|
|
@ -144,7 +146,7 @@ public sealed class DebugVM
|
||||||
Func<float> getFrameMs,
|
Func<float> getFrameMs,
|
||||||
CombatState combat)
|
CombatState combat)
|
||||||
{
|
{
|
||||||
if (combat is null) throw new ArgumentNullException(nameof(combat));
|
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||||
_getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition));
|
_getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition));
|
||||||
_getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg));
|
_getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg));
|
||||||
_getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId));
|
_getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId));
|
||||||
|
|
@ -177,21 +179,12 @@ public sealed class DebugVM
|
||||||
// Self-subscribe to combat events. Each one becomes a typed entry
|
// Self-subscribe to combat events. Each one becomes a typed entry
|
||||||
// in the ring; the panel renders them in TextColored. Replaces
|
// in the ring; the panel renders them in TextColored. Replaces
|
||||||
// the old DebugOverlay.BindCombat side-channel.
|
// the old DebugOverlay.BindCombat side-channel.
|
||||||
combat.DamageTaken += d => Push(CombatEventKind.Error,
|
_combat.DamageTaken += OnDamageTaken;
|
||||||
$"<< {d.AttackerName} hit you for {d.Damage}{(d.Critical ? " CRIT!" : "")}");
|
_combat.DamageDealtAccepted += OnDamageDealt;
|
||||||
combat.DamageDealtAccepted += d => Push(CombatEventKind.Info,
|
_combat.EvadedIncoming += OnEvadedIncoming;
|
||||||
$">> you hit {d.DefenderName} for {d.Damage}");
|
_combat.MissedOutgoing += OnMissedOutgoing;
|
||||||
combat.EvadedIncoming += attacker => Push(CombatEventKind.Warn,
|
_combat.AttackDone += OnAttackDone;
|
||||||
$"<< {attacker}'s attack missed you");
|
_combat.KillLanded += OnKillLanded;
|
||||||
combat.MissedOutgoing += defender => Push(CombatEventKind.Info,
|
|
||||||
$">> your attack missed {defender}");
|
|
||||||
combat.AttackDone += (_, weenieError) =>
|
|
||||||
{
|
|
||||||
if (weenieError != 0)
|
|
||||||
Push(CombatEventKind.Error, $"!! attack failed (error 0x{weenieError:X})");
|
|
||||||
};
|
|
||||||
combat.KillLanded += (victim, _) =>
|
|
||||||
Push(CombatEventKind.Info, $"** you killed {victim}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read-through value surfaces ───────────────────────────────────
|
// ── Read-through value surfaces ───────────────────────────────────
|
||||||
|
|
@ -510,4 +503,46 @@ public sealed class DebugVM
|
||||||
while (_combatEvents.Count > MaxCombatEvents)
|
while (_combatEvents.Count > MaxCombatEvents)
|
||||||
_combatEvents.Dequeue();
|
_combatEvents.Dequeue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_combat.DamageTaken -= OnDamageTaken;
|
||||||
|
_combat.DamageDealtAccepted -= OnDamageDealt;
|
||||||
|
_combat.EvadedIncoming -= OnEvadedIncoming;
|
||||||
|
_combat.MissedOutgoing -= OnMissedOutgoing;
|
||||||
|
_combat.AttackDone -= OnAttackDone;
|
||||||
|
_combat.KillLanded -= OnKillLanded;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDamageTaken(CombatState.DamageIncoming damage) =>
|
||||||
|
Push(
|
||||||
|
CombatEventKind.Error,
|
||||||
|
$"<< {damage.AttackerName} hit you for {damage.Damage}" +
|
||||||
|
(damage.Critical ? " CRIT!" : string.Empty));
|
||||||
|
|
||||||
|
private void OnDamageDealt(CombatState.DamageDealt damage) =>
|
||||||
|
Push(
|
||||||
|
CombatEventKind.Info,
|
||||||
|
$">> you hit {damage.DefenderName} for {damage.Damage}");
|
||||||
|
|
||||||
|
private void OnEvadedIncoming(string attacker) =>
|
||||||
|
Push(CombatEventKind.Warn, $"<< {attacker}'s attack missed you");
|
||||||
|
|
||||||
|
private void OnMissedOutgoing(string defender) =>
|
||||||
|
Push(CombatEventKind.Info, $">> your attack missed {defender}");
|
||||||
|
|
||||||
|
private void OnAttackDone(uint _, uint weenieError)
|
||||||
|
{
|
||||||
|
if (weenieError != 0)
|
||||||
|
Push(
|
||||||
|
CombatEventKind.Error,
|
||||||
|
$"!! attack failed (error 0x{weenieError:X})");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKillLanded(string victim, uint _) =>
|
||||||
|
Push(CombatEventKind.Info, $"** you killed {victim}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,31 @@ namespace AcDream.UI.ImGui;
|
||||||
/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25.
|
/// one of those present. Pivoted to the official Silk.NET extension on 2026-04-25.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImGuiBootstrapper : IDisposable
|
public interface IImGuiBootstrapper : IDisposable
|
||||||
|
{
|
||||||
|
void BeginFrame(float deltaSeconds);
|
||||||
|
void Render();
|
||||||
|
void AbortFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The upstream Silk controller does not expose a partially constructed
|
||||||
|
/// instance. A constructor failure therefore requires teardown of the owning
|
||||||
|
/// GL/window host instead of being treated as a recoverable optional-frontend
|
||||||
|
/// failure.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImGuiBootstrapperConstructionException : Exception
|
||||||
|
{
|
||||||
|
internal ImGuiBootstrapperConstructionException(Exception innerException)
|
||||||
|
: base(
|
||||||
|
"Silk ImGui construction did not publish a cleanup owner; " +
|
||||||
|
"the graphics host must be torn down.",
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ImGuiBootstrapper : IImGuiBootstrapper
|
||||||
{
|
{
|
||||||
private readonly ImGuiController _controller;
|
private readonly ImGuiController _controller;
|
||||||
|
|
||||||
|
|
@ -42,8 +66,22 @@ public sealed class ImGuiBootstrapper : IDisposable
|
||||||
// - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL)
|
// - ImGuiOpenGL3 shader + vertex-buffer init (via Silk.NET GL)
|
||||||
// - Keyboard + mouse event subscription (bound to Silk.NET IInputContext)
|
// - Keyboard + mouse event subscription (bound to Silk.NET IInputContext)
|
||||||
// - Default style = dark
|
// - Default style = dark
|
||||||
|
nint priorContext = ImGuiNET.ImGui.GetCurrentContext();
|
||||||
|
try
|
||||||
|
{
|
||||||
_controller = new ImGuiController(gl, window, input);
|
_controller = new ImGuiController(gl, window, input);
|
||||||
}
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
nint partialContext = ImGuiNET.ImGui.GetCurrentContext();
|
||||||
|
if (partialContext != 0 && partialContext != priorContext)
|
||||||
|
{
|
||||||
|
ImGuiNET.ImGui.DestroyContext(partialContext);
|
||||||
|
ImGuiNET.ImGui.SetCurrentContext(priorContext);
|
||||||
|
}
|
||||||
|
throw new ImGuiBootstrapperConstructionException(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Begin an ImGui frame. Call BEFORE any <c>ImGui.*</c> widget calls.
|
/// Begin an ImGui frame. Call BEFORE any <c>ImGui.*</c> widget calls.
|
||||||
|
|
|
||||||
59
tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs
Normal file
59
tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Combat;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Debug;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Combat;
|
||||||
|
|
||||||
|
public sealed class CombatFeedbackSlotTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExpectedOwnerUnbindCannotClearReplacement()
|
||||||
|
{
|
||||||
|
var slot = new CombatFeedbackSlot();
|
||||||
|
using DebugVM first = CreateViewModel();
|
||||||
|
using DebugVM second = CreateViewModel();
|
||||||
|
|
||||||
|
slot.Bind(first);
|
||||||
|
slot.Unbind(second);
|
||||||
|
slot.Show("first");
|
||||||
|
Assert.Single(first.RecentToasts);
|
||||||
|
Assert.Empty(second.RecentToasts);
|
||||||
|
|
||||||
|
slot.Unbind(first);
|
||||||
|
slot.Bind(second);
|
||||||
|
slot.Show("second");
|
||||||
|
Assert.Single(second.RecentToasts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DebugVM CreateViewModel() => new(
|
||||||
|
static () => Vector3.Zero,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => false,
|
||||||
|
static () => false,
|
||||||
|
static () => false,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => float.PositiveInfinity,
|
||||||
|
static () => "-",
|
||||||
|
static () => false,
|
||||||
|
static () => false,
|
||||||
|
static () => 0,
|
||||||
|
static () => 1,
|
||||||
|
static () => 0,
|
||||||
|
static () => false,
|
||||||
|
static () => "0",
|
||||||
|
static () => 0,
|
||||||
|
static () => "Clear",
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 0,
|
||||||
|
static () => 60,
|
||||||
|
static () => 16.7f,
|
||||||
|
new CombatState());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,520 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using AcDream.App.Combat;
|
||||||
|
using AcDream.App.Composition;
|
||||||
|
using AcDream.App.Diagnostics;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Settings;
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
using AcDream.UI.Abstractions.Settings;
|
||||||
|
using AcDream.UI.ImGui;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.Maths;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Composition;
|
||||||
|
|
||||||
|
public sealed class SettingsDevToolsCompositionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DisabledFrontendAppliesSettingsAndAcquiresNothingOptional()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(enabled: false);
|
||||||
|
|
||||||
|
SettingsDevToolsResult result = fixture.Compose();
|
||||||
|
|
||||||
|
Assert.Null(result.DevTools);
|
||||||
|
Assert.Equal(1, fixture.Startup.DisplayCalls);
|
||||||
|
Assert.Equal(1, fixture.Startup.AudioCalls);
|
||||||
|
Assert.Equal(0, fixture.Factory.Calls);
|
||||||
|
Assert.Equal(
|
||||||
|
[SettingsDevToolsCompositionPoint.SettingsApplied],
|
||||||
|
fixture.Points);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedInputDisablesBeforeAcquiringFrontendResources()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(inputSupported: false);
|
||||||
|
|
||||||
|
SettingsDevToolsResult result = fixture.Compose();
|
||||||
|
|
||||||
|
Assert.Null(result.DevTools);
|
||||||
|
Assert.Null(fixture.Factory.Input);
|
||||||
|
Assert.Null(fixture.Factory.Bootstrap);
|
||||||
|
Assert.Equal(1, fixture.Factory.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnabledFrontendPublishesOneCompleteOwnerAfterEveryEdge()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture();
|
||||||
|
|
||||||
|
SettingsDevToolsResult result = fixture.Compose();
|
||||||
|
|
||||||
|
Assert.Same(fixture.Publication.Owner, result.DevTools);
|
||||||
|
Assert.Equal(
|
||||||
|
Enum.GetValues<SettingsDevToolsCompositionPoint>(),
|
||||||
|
fixture.Points);
|
||||||
|
Assert.True(fixture.Factory.Input!.Activated);
|
||||||
|
Assert.Equal(4, fixture.Factory.Backend!.LayoutCalls);
|
||||||
|
Assert.Equal(1, fixture.Publication.PublishCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(OptionalFailurePoints))]
|
||||||
|
public void OptionalPrefixFailureDisablesOnlyAfterCompleteReverseCleanup(
|
||||||
|
int pointValue)
|
||||||
|
{
|
||||||
|
var point = (SettingsDevToolsCompositionPoint)pointValue;
|
||||||
|
using var fixture = new Fixture(failurePoint: point);
|
||||||
|
|
||||||
|
SettingsDevToolsResult result = fixture.Compose();
|
||||||
|
|
||||||
|
Assert.Null(result.DevTools);
|
||||||
|
Assert.Null(fixture.Publication.Owner);
|
||||||
|
Assert.Equal(
|
||||||
|
Enum.GetValues<SettingsDevToolsCompositionPoint>()
|
||||||
|
.TakeWhile(candidate => candidate <= point),
|
||||||
|
fixture.Points);
|
||||||
|
if (fixture.Factory.Input is { } input)
|
||||||
|
Assert.True(input.IsDisposalComplete);
|
||||||
|
if (fixture.Factory.Bootstrap is { } bootstrap)
|
||||||
|
Assert.Equal(1, bootstrap.DisposeCalls);
|
||||||
|
if (fixture.Factory.Backend is { } backend)
|
||||||
|
Assert.Equal(1, backend.DisposeCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<int> OptionalFailurePoints()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<int>();
|
||||||
|
foreach (SettingsDevToolsCompositionPoint point in
|
||||||
|
Enum.GetValues<SettingsDevToolsCompositionPoint>())
|
||||||
|
{
|
||||||
|
if (point is SettingsDevToolsCompositionPoint.SettingsApplied
|
||||||
|
or SettingsDevToolsCompositionPoint.DevToolsPublished)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data.Add((int)point);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailureAfterPublicationPropagatesToLifetimeOwner()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(
|
||||||
|
failurePoint: SettingsDevToolsCompositionPoint.DevToolsPublished);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
||||||
|
|
||||||
|
Assert.NotNull(fixture.Publication.Owner);
|
||||||
|
Assert.False(fixture.Publication.Owner!.IsDisposalComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CleanupFailurePropagatesAndRetrySkipsCompletedOperations()
|
||||||
|
{
|
||||||
|
using var fixture = new Fixture(
|
||||||
|
failurePoint: SettingsDevToolsCompositionPoint.InitialLayoutApplied,
|
||||||
|
backendDisposeFailures: 1);
|
||||||
|
|
||||||
|
var failure = Assert.Throws<CompositionAcquisitionException>(fixture.Compose);
|
||||||
|
Assert.False(failure.IsCleanupComplete);
|
||||||
|
Assert.Equal(1, fixture.Factory.Backend!.DisposeCalls);
|
||||||
|
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
|
||||||
|
|
||||||
|
failure.RetryCleanup();
|
||||||
|
|
||||||
|
Assert.True(failure.IsCleanupComplete);
|
||||||
|
Assert.Equal(2, fixture.Factory.Backend.DisposeCalls);
|
||||||
|
Assert.Equal(1, fixture.Factory.Input.DisposeCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BootstrapConstructionWithoutCleanupOwnerAbortsHostStartup()
|
||||||
|
{
|
||||||
|
var bootstrapFailure = (ImGuiBootstrapperConstructionException)
|
||||||
|
Activator.CreateInstance(
|
||||||
|
typeof(ImGuiBootstrapperConstructionException),
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||||
|
binder: null,
|
||||||
|
args: [new InvalidOperationException("partial Silk construction")],
|
||||||
|
culture: null)!;
|
||||||
|
using var fixture = new Fixture(bootstrapFailure: bootstrapFailure);
|
||||||
|
|
||||||
|
Assert.Throws<ImGuiBootstrapperConstructionException>(fixture.Compose);
|
||||||
|
|
||||||
|
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
|
||||||
|
Assert.Null(fixture.Publication.Owner);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
fixture.Logs,
|
||||||
|
message => message.Contains("devtools disabled", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameWindowUsesProductionPhaseAndDoesNotRetainHostClosures()
|
||||||
|
{
|
||||||
|
string source = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Rendering",
|
||||||
|
"GameWindow.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("new SettingsDevToolsCompositionPhase(", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("new AcDream.UI.ImGui.ImGuiBootstrapper(", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("getPlayerPosition: () => GetDebugPlayerPosition()", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("_runtimeSettings.ApplyStartup(", source,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Fixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SettingsDevToolsCompositionPoint? _failurePoint;
|
||||||
|
private readonly InputDispatcher _dispatcher;
|
||||||
|
private readonly FrameProfiler _profiler = new();
|
||||||
|
|
||||||
|
public Fixture(
|
||||||
|
bool enabled = true,
|
||||||
|
SettingsDevToolsCompositionPoint? failurePoint = null,
|
||||||
|
int backendDisposeFailures = 0,
|
||||||
|
bool inputSupported = true,
|
||||||
|
Exception? bootstrapFailure = null)
|
||||||
|
{
|
||||||
|
_failurePoint = failurePoint;
|
||||||
|
Factory = new Factory(
|
||||||
|
backendDisposeFailures,
|
||||||
|
inputSupported,
|
||||||
|
bootstrapFailure);
|
||||||
|
Publication = new Publication();
|
||||||
|
Startup = new StartupTarget();
|
||||||
|
Settings = new RuntimeSettingsController(
|
||||||
|
new Storage(),
|
||||||
|
QualitySettings.From,
|
||||||
|
static _ => { });
|
||||||
|
_dispatcher = InputDispatcher.CreateDetached(
|
||||||
|
new KeyboardSource(),
|
||||||
|
new MouseSource(),
|
||||||
|
new KeyBindings());
|
||||||
|
_dispatcher.Attach();
|
||||||
|
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||||
|
Host = new HostInputCameraResult(
|
||||||
|
null!,
|
||||||
|
null!,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
_dispatcher,
|
||||||
|
camera,
|
||||||
|
null);
|
||||||
|
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
|
||||||
|
Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject(
|
||||||
|
typeof(ContentEffectsAudioResult));
|
||||||
|
Dependencies = new SettingsDevToolsDependencies(
|
||||||
|
DispatchProxy.Create<IView, ViewProxy>(),
|
||||||
|
Settings,
|
||||||
|
Startup,
|
||||||
|
new HostQuiescenceGate(),
|
||||||
|
new ChatLog(),
|
||||||
|
new CombatState(),
|
||||||
|
new LocalPlayerState(new Spellbook()),
|
||||||
|
enabled
|
||||||
|
? new SettingsDevToolsOptionalDependencies(
|
||||||
|
new Facts(),
|
||||||
|
new KeyBindingTarget(),
|
||||||
|
new DeferredCanonicalWorldEntityCountSource(),
|
||||||
|
new DeferredRenderFrameDiagnosticsSource(),
|
||||||
|
new DeferredDevToolsPlayerModeCommands())
|
||||||
|
: null,
|
||||||
|
new RuntimeDiagnosticCommandSlot(),
|
||||||
|
new CombatFeedbackSlot(),
|
||||||
|
new KeyBindings(),
|
||||||
|
_profiler,
|
||||||
|
new FramebufferResizeController(new ViewportAspectState()),
|
||||||
|
Logs.Add);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SettingsDevToolsCompositionPoint> Points { get; } = [];
|
||||||
|
public List<string> Logs { get; } = [];
|
||||||
|
public Factory Factory { get; }
|
||||||
|
public Publication Publication { get; }
|
||||||
|
public StartupTarget Startup { get; }
|
||||||
|
public RuntimeSettingsController Settings { get; }
|
||||||
|
public SettingsDevToolsDependencies Dependencies { get; }
|
||||||
|
public HostInputCameraResult Host { get; }
|
||||||
|
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
|
||||||
|
public ContentEffectsAudioResult Content { get; }
|
||||||
|
|
||||||
|
public SettingsDevToolsResult Compose() =>
|
||||||
|
new SettingsDevToolsCompositionPhase(
|
||||||
|
Dependencies,
|
||||||
|
Publication,
|
||||||
|
Factory,
|
||||||
|
point =>
|
||||||
|
{
|
||||||
|
Points.Add(point);
|
||||||
|
if (point == _failurePoint)
|
||||||
|
throw new InvalidOperationException($"fault at {point}");
|
||||||
|
}).Compose(Platform, Host, Content);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Publication.Owner?.Dispose();
|
||||||
|
_dispatcher.Dispose();
|
||||||
|
_profiler.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Publication : IGameWindowSettingsDevToolsPublication
|
||||||
|
{
|
||||||
|
public DevToolsCompositionOwner? Owner { get; private set; }
|
||||||
|
public int PublishCalls { get; private set; }
|
||||||
|
|
||||||
|
public void PublishDevTools(DevToolsCompositionOwner value)
|
||||||
|
{
|
||||||
|
if (Owner is not null)
|
||||||
|
throw new InvalidOperationException("duplicate publication");
|
||||||
|
Owner = value;
|
||||||
|
PublishCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Factory(
|
||||||
|
int backendDisposeFailures,
|
||||||
|
bool inputSupported,
|
||||||
|
Exception? bootstrapFailure)
|
||||||
|
: ISettingsDevToolsCompositionFactory
|
||||||
|
{
|
||||||
|
public int Calls { get; private set; }
|
||||||
|
public InputContext? Input { get; private set; }
|
||||||
|
public Bootstrap? Bootstrap { get; private set; }
|
||||||
|
public Backend? Backend { get; private set; }
|
||||||
|
|
||||||
|
public bool IsSupported(IInputContext input)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return inputSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDevToolsInputContext CreateInputContext(
|
||||||
|
IInputContext input,
|
||||||
|
HostQuiescenceGate quiescence)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return Input = new InputContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IImGuiBootstrapper CreateBootstrap(
|
||||||
|
GL gl,
|
||||||
|
IView window,
|
||||||
|
IInputContext input)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
if (bootstrapFailure is not null)
|
||||||
|
throw bootstrapFailure;
|
||||||
|
return Bootstrap = new Bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImGuiPanelHost CreatePanelHost()
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return new ImGuiPanelHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDevToolsFrameBackend CreateBackend(
|
||||||
|
IImGuiBootstrapper bootstrap,
|
||||||
|
ImGuiPanelHost panels)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return Backend = new Backend(
|
||||||
|
(Bootstrap)bootstrap,
|
||||||
|
backendDisposeFailures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InputContext : IDevToolsInputContext
|
||||||
|
{
|
||||||
|
public bool Activated { get; private set; }
|
||||||
|
public int DisposeCalls { get; private set; }
|
||||||
|
public bool IsDisposalComplete { get; private set; }
|
||||||
|
public nint Handle => 0;
|
||||||
|
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
||||||
|
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
||||||
|
public IReadOnlyList<IKeyboard> Keyboards { get; } = [];
|
||||||
|
public IReadOnlyList<IMouse> Mice { get; } = [];
|
||||||
|
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
||||||
|
#pragma warning disable CS0067
|
||||||
|
public event Action<IInputDevice, bool>? ConnectionChanged;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
public void Activate() => Activated = true;
|
||||||
|
public void Deactivate() => Activated = false;
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCalls++;
|
||||||
|
Activated = false;
|
||||||
|
IsDisposalComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Bootstrap : IImGuiBootstrapper
|
||||||
|
{
|
||||||
|
public int DisposeCalls { get; private set; }
|
||||||
|
public void BeginFrame(float deltaSeconds) { }
|
||||||
|
public void Render() { }
|
||||||
|
public void AbortFrame() { }
|
||||||
|
public void Dispose() => DisposeCalls++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Backend(
|
||||||
|
Bootstrap bootstrap,
|
||||||
|
int remainingDisposeFailures) : IDevToolsFrameBackend
|
||||||
|
{
|
||||||
|
private int _remainingDisposeFailures = remainingDisposeFailures;
|
||||||
|
public int DisposeCalls { get; private set; }
|
||||||
|
public int LayoutCalls { get; private set; }
|
||||||
|
public void BeginFrame(float deltaSeconds) { }
|
||||||
|
public void AbortFrame() { }
|
||||||
|
public bool BeginMainMenuBar() => false;
|
||||||
|
public void EndMainMenuBar() { }
|
||||||
|
public bool BeginMenu(string label) => false;
|
||||||
|
public void EndMenu() { }
|
||||||
|
public bool MenuItem(string label, string? shortcut = null, bool selected = false) => false;
|
||||||
|
public void Separator() { }
|
||||||
|
public void RenderPanels(AcDream.UI.Abstractions.PanelContext context) { }
|
||||||
|
public void RenderDrawData() { }
|
||||||
|
public void SetWindowLayout(
|
||||||
|
string title,
|
||||||
|
Vector2 position,
|
||||||
|
Vector2 size,
|
||||||
|
DevToolsPanelLayoutCondition condition) => LayoutCalls++;
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeCalls++;
|
||||||
|
if (_remainingDisposeFailures-- > 0)
|
||||||
|
throw new InvalidOperationException("backend cleanup failed");
|
||||||
|
bootstrap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StartupTarget : IRuntimeSettingsStartupTarget
|
||||||
|
{
|
||||||
|
public int DisplayCalls { get; private set; }
|
||||||
|
public int AudioCalls { get; private set; }
|
||||||
|
public void ApplyDisplay(DisplaySettings display) => DisplayCalls++;
|
||||||
|
public void ApplyAudio(AudioSettings audio) => AudioCalls++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Storage : IRuntimeSettingsStorage
|
||||||
|
{
|
||||||
|
public SettingsStore? LayoutStore => null;
|
||||||
|
public string Location => "memory://settings";
|
||||||
|
public DisplaySettings LoadDisplay() => DisplaySettings.Default;
|
||||||
|
public AudioSettings LoadAudio() => AudioSettings.Default;
|
||||||
|
public GameplaySettings LoadGameplay() => GameplaySettings.Default;
|
||||||
|
public ChatSettings LoadChat() => ChatSettings.Default;
|
||||||
|
public CharacterSettings LoadCharacter(string toonKey) => CharacterSettings.Default;
|
||||||
|
public void SaveDisplay(DisplaySettings display) { }
|
||||||
|
public void SaveAudio(AudioSettings audio) { }
|
||||||
|
public void SaveGameplay(GameplaySettings gameplay) { }
|
||||||
|
public void SaveChat(ChatSettings chat) { }
|
||||||
|
public void SaveCharacter(string toonKey, CharacterSettings character) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class KeyBindingTarget : IRuntimeKeyBindingTarget
|
||||||
|
{
|
||||||
|
public void Apply(KeyBindings bindings) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Facts : IDevToolsRuntimeFacts
|
||||||
|
{
|
||||||
|
public Vector3 PlayerPosition => default;
|
||||||
|
public float PlayerHeadingDegrees => 0;
|
||||||
|
public uint PlayerCellId => 0;
|
||||||
|
public bool PlayerOnGround => false;
|
||||||
|
public bool InPlayerMode => false;
|
||||||
|
public bool InFlyMode => false;
|
||||||
|
public float VerticalVelocity => 0;
|
||||||
|
public int EntityCount => 0;
|
||||||
|
public int AnimatedCount => 0;
|
||||||
|
public int VisibleLandblocks => 0;
|
||||||
|
public int TotalLandblocks => 0;
|
||||||
|
public int ShadowObjectCount => 0;
|
||||||
|
public float NearestObjectDistance => float.PositiveInfinity;
|
||||||
|
public string NearestObjectLabel => "-";
|
||||||
|
public bool Colliding => false;
|
||||||
|
public bool CollisionWireframesVisible => false;
|
||||||
|
public int StreamingRadius => 0;
|
||||||
|
public float MouseSensitivity => 1;
|
||||||
|
public float ChaseDistance => 0;
|
||||||
|
public bool RmbOrbitHeld => false;
|
||||||
|
public string HourName => "0";
|
||||||
|
public float DayFraction => 0;
|
||||||
|
public string Weather => "Clear";
|
||||||
|
public int ActiveLights => 0;
|
||||||
|
public int RegisteredLights => 0;
|
||||||
|
public int ParticleCount => 0;
|
||||||
|
public float Fps => 60;
|
||||||
|
public float FrameMilliseconds => 16.7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class KeyboardSource : IKeyboardSource
|
||||||
|
{
|
||||||
|
#pragma warning disable CS0067
|
||||||
|
public event Action<Key, ModifierMask>? KeyDown;
|
||||||
|
public event Action<Key, ModifierMask>? KeyUp;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
public bool IsHeld(Key key) => false;
|
||||||
|
public ModifierMask CurrentModifiers => ModifierMask.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MouseSource : IMouseSource
|
||||||
|
{
|
||||||
|
#pragma warning disable CS0067
|
||||||
|
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||||
|
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||||
|
public event Action<float, float>? MouseMove;
|
||||||
|
public event Action<float>? Scroll;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
public bool IsHeld(MouseButton button) => false;
|
||||||
|
public bool WantCaptureMouse => false;
|
||||||
|
public bool WantCaptureKeyboard => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewProxy : DispatchProxy
|
||||||
|
{
|
||||||
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
if (targetMethod?.Name == "get_Size")
|
||||||
|
return new Vector2D<int>(1280, 720);
|
||||||
|
Type type = targetMethod?.ReturnType ?? typeof(void);
|
||||||
|
if (type == typeof(void))
|
||||||
|
return null;
|
||||||
|
return type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
return directory.FullName;
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -283,6 +283,7 @@ public sealed class DevToolsFramePresenterTests
|
||||||
|
|
||||||
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
|
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
|
||||||
{
|
{
|
||||||
|
public void Dispose() { }
|
||||||
public HashSet<string> OpenMenus { get; } = [];
|
public HashSet<string> OpenMenus { get; } = [];
|
||||||
public HashSet<string> ClickedItems { get; } = [];
|
public HashSet<string> ClickedItems { get; } = [];
|
||||||
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
|
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class DevToolsRuntimeSourcesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FrameDiagnosticsLateBindingPreservesSeedAndExpectedOwnerRelease()
|
||||||
|
{
|
||||||
|
var source = new DeferredRenderFrameDiagnosticsSource();
|
||||||
|
var first = new FrameSource(144, 6.9);
|
||||||
|
var other = new FrameSource(30, 33.3);
|
||||||
|
|
||||||
|
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||||
|
source.Bind(first);
|
||||||
|
Assert.Equal(144, source.Snapshot.Fps);
|
||||||
|
source.Unbind(other);
|
||||||
|
Assert.Equal(144, source.Snapshot.Fps);
|
||||||
|
source.Unbind(first);
|
||||||
|
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||||
|
|
||||||
|
source.Bind(other);
|
||||||
|
source.Deactivate();
|
||||||
|
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FrameDiagnosticsRejectsACompetingCanonicalOwner()
|
||||||
|
{
|
||||||
|
var source = new DeferredRenderFrameDiagnosticsSource();
|
||||||
|
source.Bind(new FrameSource(60, 16.7));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
source.Bind(new FrameSource(120, 8.3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerModeCommandSlotUsesLiveTargetAndRejectsStaleRelease()
|
||||||
|
{
|
||||||
|
var source = new DeferredDevToolsPlayerModeCommands();
|
||||||
|
var first = new PlayerModeTarget();
|
||||||
|
var other = new PlayerModeTarget();
|
||||||
|
|
||||||
|
source.ToggleFlyOrChase();
|
||||||
|
source.Bind(first);
|
||||||
|
source.ToggleFlyOrChase();
|
||||||
|
source.Unbind(other);
|
||||||
|
source.ToggleFlyOrChase();
|
||||||
|
source.Unbind(first);
|
||||||
|
source.ToggleFlyOrChase();
|
||||||
|
|
||||||
|
Assert.Equal(2, first.ToggleCalls);
|
||||||
|
Assert.Equal(0, other.ToggleCalls);
|
||||||
|
|
||||||
|
source.Bind(other);
|
||||||
|
source.Deactivate();
|
||||||
|
source.ToggleFlyOrChase();
|
||||||
|
Assert.Equal(0, other.ToggleCalls);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld()
|
||||||
|
{
|
||||||
|
var source = new DeferredCanonicalWorldEntityCountSource();
|
||||||
|
var first = new GpuWorldState();
|
||||||
|
var other = new GpuWorldState();
|
||||||
|
|
||||||
|
source.Bind(first);
|
||||||
|
source.Unbind(other);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => source.Bind(other));
|
||||||
|
source.Unbind(first);
|
||||||
|
source.Bind(other);
|
||||||
|
source.Deactivate();
|
||||||
|
|
||||||
|
Assert.Equal(0, source.EntityCount);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FrameSource(double fps, double milliseconds)
|
||||||
|
: IRenderFrameDiagnosticsSnapshotSource
|
||||||
|
{
|
||||||
|
public RenderFrameDiagnosticsSnapshot Snapshot { get; } =
|
||||||
|
RenderFrameDiagnosticsSnapshot.Initial with
|
||||||
|
{
|
||||||
|
Fps = fps,
|
||||||
|
FrameMilliseconds = milliseconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlayerModeTarget : IDevToolsPlayerModeTarget
|
||||||
|
{
|
||||||
|
public int ToggleCalls { get; private set; }
|
||||||
|
public void ToggleFlyOrChase() => ToggleCalls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,22 @@ public sealed class FramebufferResizeControllerTests
|
||||||
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
|
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DevToolsBindingReleasesExpectedTargetWithoutReplayingResize()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var owner = new FramebufferResizeController(new ViewportAspectState());
|
||||||
|
var target = new DevTools(calls);
|
||||||
|
using var binding = new FramebufferDevToolsBinding(owner, target);
|
||||||
|
|
||||||
|
owner.Resize(900, 700);
|
||||||
|
binding.Dispose();
|
||||||
|
binding.Dispose();
|
||||||
|
owner.Resize(1000, 800);
|
||||||
|
|
||||||
|
Assert.Equal(["devtools:900x700"], calls);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
|
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
|
||||||
{
|
{
|
||||||
public void ResizeViewport(int width, int height) =>
|
public void ResizeViewport(int width, int height) =>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
|
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
|
||||||
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
|
string settingsComposition = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Composition",
|
||||||
|
"SettingsDevToolsComposition.cs"));
|
||||||
|
Assert.Contains("new DevToolsFramePresenter(", settingsComposition);
|
||||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
|
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
|
||||||
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
|
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
|
||||||
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
||||||
|
|
@ -119,7 +125,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||||
"new ResourceShutdownStage(\"render frontends\"",
|
"new ResourceShutdownStage(\"render frontends\"",
|
||||||
"new(\"developer tools\"",
|
"new(\"developer tools\"",
|
||||||
"_devToolsBackend?.Dispose()",
|
"owner.DisposeFrontend()",
|
||||||
"new(\"portal tunnel\"",
|
"new(\"portal tunnel\"",
|
||||||
"new(\"paperdoll viewport\"",
|
"new(\"paperdoll viewport\"",
|
||||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||||
|
|
@ -135,7 +141,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
source,
|
source,
|
||||||
"_frameGraphs.Withdraw();",
|
"_frameGraphs.Withdraw();",
|
||||||
"_devToolsBackend?.Dispose()",
|
"owner.DisposeFrontend()",
|
||||||
"new ResourceShutdownStage(\"input\"",
|
"new ResourceShutdownStage(\"input\"",
|
||||||
"_input?.Dispose();",
|
"_input?.Dispose();",
|
||||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"this).Compose(platform);",
|
"this).Compose(platform);",
|
||||||
"new ContentEffectsAudioCompositionPhase(",
|
"new ContentEffectsAudioCompositionPhase(",
|
||||||
"this).Compose(platform, hostInputCamera);",
|
"this).Compose(platform, hostInputCamera);",
|
||||||
"_runtimeSettings.ApplyStartup(",
|
"new SettingsDevToolsCompositionPhase(",
|
||||||
"new RuntimeSettingsStartupTargets(",
|
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||||
"_uiHost = _retailUiLease.AcquireHost(",
|
"_uiHost = _retailUiLease.AcquireHost(",
|
||||||
"_uiHost.WireMouse(m)",
|
"_uiHost.WireMouse(m)",
|
||||||
"_uiHost.WireKeyboard(kb)",
|
"_uiHost.WireKeyboard(kb)",
|
||||||
|
|
@ -138,10 +138,19 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
|
|
||||||
Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal);
|
Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal);
|
||||||
|
string settingsPhase = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Composition",
|
||||||
|
"SettingsDevToolsComposition.cs"));
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
settingsPhase,
|
||||||
|
"debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;",
|
||||||
|
"debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;",
|
||||||
|
"debugVm.ToggleCollisionWires =");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
load,
|
load,
|
||||||
"_debugVm.CycleTimeOfDay =",
|
|
||||||
"_runtimeDiagnosticCommands.CycleTimeOfDay;",
|
|
||||||
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
|
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
|
||||||
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
|
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
|
||||||
}
|
}
|
||||||
|
|
@ -267,11 +276,21 @@ public sealed class GameWindowSlice8BoundaryTests
|
||||||
"Window.Create(options)");
|
"Window.Create(options)");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
load,
|
load,
|
||||||
"_runtimeSettings.ApplyStartup(",
|
"new SettingsDevToolsCompositionPhase(",
|
||||||
"if (DevToolsEnabled)",
|
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||||
"TerrainAtlas.Build(",
|
"TerrainAtlas.Build(",
|
||||||
"_runtimeSettings.BindRuntimeTargets(",
|
"_runtimeSettings.BindRuntimeTargets(",
|
||||||
"_liveSessionHost.Start(_options)");
|
"_liveSessionHost.Start(_options)");
|
||||||
|
string settingsPhase = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepoRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.App",
|
||||||
|
"Composition",
|
||||||
|
"SettingsDevToolsComposition.cs"));
|
||||||
|
AssertAppearsInOrder(
|
||||||
|
settingsPhase,
|
||||||
|
"_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);",
|
||||||
|
"_dependencies.DevTools is { } optional");
|
||||||
AssertAppearsInOrder(
|
AssertAppearsInOrder(
|
||||||
shutdown,
|
shutdown,
|
||||||
"_runtimeSettings.UnbindViewModel()",
|
"_runtimeSettings.UnbindViewModel()",
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,31 @@ public sealed class RuntimeSettingsControllerTests
|
||||||
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewModelBindingReleasesOnlyItsExpectedInstance()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
using InputDispatcher dispatcher = CreateDispatcher();
|
||||||
|
RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding(
|
||||||
|
new KeyBindings(),
|
||||||
|
dispatcher,
|
||||||
|
static _ => { });
|
||||||
|
|
||||||
|
first.Dispose();
|
||||||
|
RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding(
|
||||||
|
new KeyBindings(),
|
||||||
|
dispatcher,
|
||||||
|
static _ => { });
|
||||||
|
first.Dispose();
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
controller.CreateViewModelBinding(
|
||||||
|
new KeyBindings(),
|
||||||
|
dispatcher,
|
||||||
|
static _ => { }));
|
||||||
|
second.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartupRetryResumesAfterLastSuccessfulStage()
|
public void StartupRetryResumesAfterLastSuccessfulStage()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatVMLastTellSenderTests
|
public sealed class ChatVMLastTellSenderTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DisposeDetachesTranscriptSubscriptionExactlyOnce()
|
||||||
|
{
|
||||||
|
var log = new ChatLog();
|
||||||
|
var vm = new ChatVM(log);
|
||||||
|
log.OnTellReceived("Before", "ping", 0x5000_0001u);
|
||||||
|
|
||||||
|
vm.Dispose();
|
||||||
|
vm.Dispose();
|
||||||
|
log.OnTellReceived("After", "pong", 0x5000_0002u);
|
||||||
|
|
||||||
|
Assert.Equal("Before", vm.LastIncomingTellSender);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LastIncomingTellSender_StartsNull()
|
public void LastIncomingTellSender_StartsNull()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,22 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DebugVMTests
|
public sealed class DebugVMTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DisposeDetachesEveryCombatSubscriptionExactlyOnce()
|
||||||
|
{
|
||||||
|
var combat = new CombatState();
|
||||||
|
DebugVM vm = NewVm(combat);
|
||||||
|
combat.OnKillerNotification("first", 1);
|
||||||
|
Assert.Single(vm.CombatEvents);
|
||||||
|
|
||||||
|
vm.Dispose();
|
||||||
|
vm.Dispose();
|
||||||
|
combat.OnKillerNotification("second", 2);
|
||||||
|
combat.OnAttackDone(3, 4);
|
||||||
|
|
||||||
|
Assert.Single(vm.CombatEvents);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
|
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
|
||||||
/// constructor source. Tests that don't care about a particular source
|
/// constructor source. Tests that don't care about a particular source
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue