acdream/src/AcDream.App/Rendering/DevToolsFramePresenter.cs
Erik 7593078774 refactor(runtime): move session lifetime and ordered transport
Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state.

Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips.

Co-authored-by: Codex <codex@openai.com>
2026-07-25 19:39:24 +02:00

491 lines
15 KiB
C#

using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.UI.Abstractions;
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 ImGuiNET;
namespace AcDream.App.Rendering;
internal enum DevToolsPanelLayoutCondition
{
FirstUseEver,
Always,
}
internal enum DevToolsPanelKind
{
Vitals,
Chat,
Debug,
Settings,
}
internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
{
void BeginFrame(float deltaSeconds);
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
}
internal interface IDevToolsFrameBackend : IDisposable
{
void BeginFrame(float deltaSeconds);
void AbortFrame();
bool BeginMainMenuBar();
void EndMainMenuBar();
bool BeginMenu(string label);
void EndMenu();
bool MenuItem(string label, string? shortcut = null, bool selected = false);
void Separator();
void RenderPanels(PanelContext context);
void RenderDrawData();
void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition);
}
internal interface IDevToolsCameraMenuOperations
{
bool IsFlyMode { get; }
bool CollideCamera { get; set; }
void ToggleFlyOrChase();
}
internal interface IDevToolsCommandBusSource
{
ICommandBus Current { get; }
}
internal interface IDevToolsPanelSet
{
bool Contains(DevToolsPanelKind kind);
string? GetTitle(DevToolsPanelKind kind);
bool IsVisible(DevToolsPanelKind kind);
void Toggle(DevToolsPanelKind kind);
void FocusChatInput();
void AddDebugToast(string message);
}
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
{
private readonly IImGuiBootstrapper _bootstrap;
private readonly ImGuiPanelHost _panels;
private bool _disposed;
public ImGuiDevToolsFrameBackend(
IImGuiBootstrapper bootstrap,
ImGuiPanelHost panels)
{
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
_panels = panels ?? throw new ArgumentNullException(nameof(panels));
}
public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds);
public void AbortFrame() => _bootstrap.AbortFrame();
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label);
public void EndMenu() => ImGuiNET.ImGui.EndMenu();
public bool MenuItem(string label, string? shortcut = null, bool selected = false) =>
ImGuiNET.ImGui.MenuItem(label, shortcut ?? string.Empty, selected);
public void Separator() => ImGuiNET.ImGui.Separator();
public void RenderPanels(PanelContext context) => _panels.RenderAll(context);
public void RenderDrawData() => _bootstrap.Render();
public void SetWindowLayout(
string title,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition)
{
ImGuiCond imguiCondition = condition switch
{
DevToolsPanelLayoutCondition.FirstUseEver => ImGuiCond.FirstUseEver,
DevToolsPanelLayoutCondition.Always => ImGuiCond.Always,
_ => throw new ArgumentOutOfRangeException(nameof(condition)),
};
ImGuiNET.ImGui.SetWindowPos(title, position, imguiCondition);
ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition);
}
public void Dispose()
{
if (_disposed)
return;
_bootstrap.Dispose();
_disposed = true;
}
}
/// <summary>Mutable late-composition seam for player-mode menu operations.</summary>
internal sealed class DevToolsCameraMenuOperations : IDevToolsCameraMenuOperations
{
private readonly CameraController _camera;
private readonly IDevToolsPlayerModeCommands _playerMode;
public DevToolsCameraMenuOperations(
CameraController camera,
IDevToolsPlayerModeCommands playerMode)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
}
public bool IsFlyMode => _camera.IsFlyMode;
public bool CollideCamera
{
get => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
set => AcDream.Core.Rendering.CameraDiagnostics.CollideCamera = value;
}
public void ToggleFlyOrChase() => _playerMode.ToggleFlyOrChase();
}
/// <summary>Resolves the reconnect-safe command bus at draw time.</summary>
internal sealed class DevToolsCommandBusSource : IDevToolsCommandBusSource
{
private ILiveUiSessionTarget? _session;
private bool _deactivated;
public ICommandBus Current =>
!_deactivated && _session is { } session
? session.Commands
: NullCommandBus.Instance;
public void Bind(ILiveUiSessionTarget 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 IDisposable BindOwned(ILiveUiSessionTarget session)
{
ArgumentNullException.ThrowIfNull(session);
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_session is not null)
{
throw new InvalidOperationException(
"Developer command-bus authority is already bound.");
}
_session = session;
return new Binding(this, session);
}
public void Unbind(ILiveUiSessionTarget session)
{
ArgumentNullException.ThrowIfNull(session);
if (ReferenceEquals(_session, session))
_session = null;
}
public void Deactivate()
{
_deactivated = true;
_session = null;
}
private sealed class Binding : IDisposable
{
private DevToolsCommandBusSource? _owner;
private readonly ILiveUiSessionTarget _expected;
public Binding(
DevToolsCommandBusSource owner,
ILiveUiSessionTarget expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
}
}
/// <summary>Typed panel operations used by menu and input presentation.</summary>
internal sealed class DevToolsPanelSet : IDevToolsPanelSet
{
private readonly VitalsPanel _vitals;
private readonly ChatPanel _chat;
private readonly DebugPanel _debug;
private readonly DebugVM _debugViewModel;
private readonly SettingsPanel? _settings;
public DevToolsPanelSet(
VitalsPanel vitals,
ChatPanel chat,
DebugPanel debug,
DebugVM debugViewModel,
SettingsPanel? settings)
{
_vitals = vitals ?? throw new ArgumentNullException(nameof(vitals));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_debug = debug ?? throw new ArgumentNullException(nameof(debug));
_debugViewModel = debugViewModel
?? throw new ArgumentNullException(nameof(debugViewModel));
_settings = settings;
}
public bool Contains(DevToolsPanelKind kind) =>
kind != DevToolsPanelKind.Settings || _settings is not null;
public string? GetTitle(DevToolsPanelKind kind) => GetPanel(kind)?.Title;
public bool IsVisible(DevToolsPanelKind kind) => GetPanel(kind)?.IsVisible == true;
public void Toggle(DevToolsPanelKind kind)
{
if (GetPanel(kind) is { } panel)
panel.IsVisible = !panel.IsVisible;
}
public void FocusChatInput() => _chat.FocusInput();
public void AddDebugToast(string message) => _debugViewModel.AddToast(message);
private IPanel? GetPanel(DevToolsPanelKind kind) => kind switch
{
DevToolsPanelKind.Vitals => _vitals,
DevToolsPanelKind.Chat => _chat,
DevToolsPanelKind.Debug => _debug,
DevToolsPanelKind.Settings => _settings,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};
}
/// <summary>
/// Owns the optional ImGui developer frame, menu policy, panel actions, and
/// reusable default layout. Retained gameplay UI remains a separate earlier
/// presentation phase.
/// </summary>
internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle
{
private readonly IDevToolsFrameBackend _backend;
private readonly IDevToolsCameraMenuOperations _camera;
private readonly IDevToolsCommandBusSource _commands;
private readonly IDevToolsPanelSet _panels;
private readonly AcDream.App.Diagnostics.FrameProfiler _profiler;
private bool _frameOpen;
public DevToolsFramePresenter(
IDevToolsFrameBackend backend,
IDevToolsCameraMenuOperations camera,
IDevToolsCommandBusSource commands,
AcDream.App.Diagnostics.FrameProfiler profiler,
IDevToolsPanelSet panels)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_panels = panels ?? throw new ArgumentNullException(nameof(panels));
}
public void BeginFrame(float deltaSeconds)
{
if (_frameOpen)
{
throw new InvalidOperationException(
"The previous developer-tools frame must render or abort before a new frame begins.");
}
_frameOpen = true;
_backend.BeginFrame(deltaSeconds);
}
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
{
if (!_frameOpen)
{
throw new InvalidOperationException(
"BeginFrame must open the developer-tools frame before Render.");
}
var context = new PanelContext((float)deltaSeconds, _commands.Current);
DrawMenu(viewportWidth, viewportHeight);
_backend.RenderPanels(context);
using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui);
_backend.RenderDrawData();
_frameOpen = false;
}
public void AbortFrame()
{
if (!_frameOpen)
return;
try
{
_backend.AbortFrame();
}
finally
{
_frameOpen = false;
}
}
public void ToggleDebugPanel()
{
_panels.Toggle(DevToolsPanelKind.Debug);
_panels.AddDebugToast(
$"Debug panel {(_panels.IsVisible(DevToolsPanelKind.Debug) ? "ON" : "OFF")}");
}
public void FocusChatInput() => _panels.FocusChatInput();
public void ToggleSettingsPanel()
{
_panels.Toggle(DevToolsPanelKind.Settings);
}
public void ResetLayout(
int viewportWidth,
int viewportHeight,
DevToolsPanelLayoutCondition condition)
{
float width = Math.Max(viewportWidth, 480);
float height = Math.Max(viewportHeight, 320);
SetLayout(
DevToolsPanelKind.Vitals,
new Vector2(10f, 30f),
new Vector2(220f, 110f),
condition);
SetLayout(
DevToolsPanelKind.Chat,
new Vector2(10f, height - 320f),
new Vector2(450f, 300f),
condition);
SetLayout(
DevToolsPanelKind.Debug,
new Vector2(width - 380f, 30f),
new Vector2(370f, 520f),
condition);
if (_panels.Contains(DevToolsPanelKind.Settings))
{
SetLayout(
DevToolsPanelKind.Settings,
new Vector2((width - 700f) * 0.5f, (height - 500f) * 0.5f),
new Vector2(700f, 500f),
condition);
}
}
private void DrawMenu(int viewportWidth, int viewportHeight)
{
if (!_backend.BeginMainMenuBar())
return;
try
{
if (_backend.BeginMenu("View"))
{
try
{
if (_panels.Contains(DevToolsPanelKind.Settings)
&& _backend.MenuItem("Settings", "F11"))
{
_panels.Toggle(DevToolsPanelKind.Settings);
}
if (_backend.MenuItem("Vitals"))
_panels.Toggle(DevToolsPanelKind.Vitals);
if (_backend.MenuItem("Chat"))
_panels.Toggle(DevToolsPanelKind.Chat);
if (_backend.MenuItem("Debug", "Ctrl+F1"))
_panels.Toggle(DevToolsPanelKind.Debug);
_backend.Separator();
if (_backend.MenuItem("Reset window layout"))
ResetLayout(
viewportWidth,
viewportHeight,
DevToolsPanelLayoutCondition.Always);
}
finally
{
_backend.EndMenu();
}
}
if (_backend.BeginMenu("Camera"))
{
try
{
string flyLabel = _camera.IsFlyMode
? "Exit Free-Fly Mode"
: "Enter Free-Fly Mode";
if (_backend.MenuItem(flyLabel, "Ctrl+Shift+F"))
_camera.ToggleFlyOrChase();
bool collide = _camera.CollideCamera;
if (_backend.MenuItem(
"Collide Camera (spring arm)",
selected: collide))
{
_camera.CollideCamera = !collide;
}
}
finally
{
_backend.EndMenu();
}
}
}
finally
{
_backend.EndMainMenuBar();
}
}
private void SetLayout(
DevToolsPanelKind panel,
Vector2 position,
Vector2 size,
DevToolsPanelLayoutCondition condition)
{
string? title = _panels.GetTitle(panel);
if (!string.IsNullOrEmpty(title))
_backend.SetWindowLayout(title, position, size, condition);
}
}