diff --git a/src/AcDream.App/Rendering/DebugOverlay.cs b/src/AcDream.App/Rendering/DebugOverlay.cs deleted file mode 100644 index db2ba2c..0000000 --- a/src/AcDream.App/Rendering/DebugOverlay.cs +++ /dev/null @@ -1,472 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Chat; -using AcDream.Core.Combat; - -namespace AcDream.App.Rendering; - -/// -/// Screen-space debug HUD. Composes panels on top of the 3D scene using a -/// + . Panels can be -/// toggled independently (info / stats / controls-help / compass). -/// -/// The overlay is stateless w.r.t. game state — callers populate a -/// each frame and pass it to . -/// -public sealed class DebugOverlay -{ - private readonly TextRenderer _text; - private readonly BitmapFont _font; - - public bool ShowInfoPanel { get; set; } = true; - public bool ShowStatsPanel { get; set; } = true; - public bool ShowHelpPanel { get; set; } = false; - public bool ShowCompass { get; set; } = true; - public bool ShowChatPanel { get; set; } = true; - public bool ShowEventPanel { get; set; } = true; - - /// - /// Live chat log to render in the bottom-left chat panel. Hook this - /// up to the session's ChatLog so server-sent ChannelBroadcast / Tell / - /// HearSpeech / system messages appear on screen. - /// - public ChatLog? Chat { get; set; } - - /// Live combat state for damage floaters + target HP display. - public CombatState? Combat { get; set; } - - // Tail of recent combat events, captured from CombatState callbacks so - // they stay on-screen long enough to read even when you're moving fast. - private readonly List<(string text, Vector4 color, float timeLeft)> _eventTail = new(); - private const int MaxEventTail = 8; - private const float EventHoldSec = 5f; - - /// - /// Bind to CombatState events so incoming damage / evasion events - /// surface as transient event-log lines. Call once after Combat is set. - /// - public void BindCombat(CombatState combat) - { - combat.DamageTaken += d => PushEvent( - $"<< {d.AttackerName} hits you for {d.Damage}{(d.Critical ? " CRIT!" : "")}", Red); - combat.DamageDealtAccepted += d => PushEvent( - $">> you hit {d.DefenderName} for {d.Damage}", Yellow); - combat.EvadedIncoming += a => PushEvent( - $"<< {a}'s attack misses you", Green); - combat.MissedOutgoing += a => PushEvent( - $">> your attack misses {a}", Grey); - } - - private void PushEvent(string text, Vector4 color) - { - _eventTail.Add((text, color, EventHoldSec)); - while (_eventTail.Count > MaxEventTail) _eventTail.RemoveAt(0); - } - - // Toast state for transient notifications (e.g. "wireframes off"). - private string? _toastText; - private Vector4 _toastColor = White; - private float _toastTimeLeft; - - private static readonly Vector4 White = new(1f, 1f, 1f, 1f); - private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f); - private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f); - private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f); - private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f); - private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f); - private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f); - private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f); - - /// Per-frame state snapshot from the caller. See . - public readonly record struct Snapshot( - float Fps, - float FrameTimeMs, - Vector3 PlayerPos, - float HeadingDeg, - uint CellId, - bool OnGround, - bool InPlayerMode, - bool InFlyMode, - float VerticalVelocity, - int EntityCount, - int AnimatedCount, - int LandblocksVisible, - int LandblocksTotal, - int ShadowObjectCount, - float NearestObjDist, - string NearestObjLabel, - bool Colliding, - bool DebugWireframes, - int StreamingRadius, - float MouseSensitivity, - float ChaseDistance, - bool RmbOrbit, - // Phase G.1/G.2 — sky + weather + lighting - string HourName = "", - float DayFraction = 0f, - string Weather = "Clear", - int ActiveLights = 0, - int RegisteredLights = 0, - int ParticleCount = 0); - - public DebugOverlay(TextRenderer text, BitmapFont font) - { - _text = text; - _font = font; - } - - /// Show a short message in the center-top for seconds. - public void Toast(string message, float durationSec = 1.5f, Vector4? color = null) - { - _toastText = message; - _toastColor = color ?? Yellow; - _toastTimeLeft = durationSec; - } - - /// Advance toast timer. Call once per frame with dt in seconds. - public void Update(float dt) - { - if (_toastTimeLeft > 0f) - { - _toastTimeLeft -= dt; - if (_toastTimeLeft <= 0f) - _toastText = null; - } - - // Age event-tail entries; drop the ones that expired this frame. - for (int i = _eventTail.Count - 1; i >= 0; i--) - { - var e = _eventTail[i]; - e.timeLeft -= dt; - if (e.timeLeft <= 0f) _eventTail.RemoveAt(i); - else _eventTail[i] = e; - } - } - - public void Draw(Snapshot s, Vector2 screenSize) - { - _text.Begin(screenSize); - - if (ShowInfoPanel) DrawInfoPanel(s); - if (ShowStatsPanel) DrawStatsPanel(s, screenSize); - if (ShowCompass) DrawCompass(s, screenSize); - if (ShowHelpPanel) DrawHelpPanel(screenSize); - if (ShowChatPanel) DrawChatPanel(screenSize); - if (ShowEventPanel) DrawEventPanel(screenSize); - DrawHintBar(screenSize); - DrawToast(screenSize); - - _text.Flush(_font); - } - - // ────────────────────────────────────────────────────────────────────── - // Info panel — top-left: mode, position, heading, ground, nearest-obj. - // ────────────────────────────────────────────────────────────────────── - - private void DrawInfoPanel(Snapshot s) - { - var lines = new List<(string text, Vector4 color)>(); - - string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT "); - var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey); - lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}", - modeColor)); - - lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White)); - lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White)); - - var gColor = s.OnGround ? Green : Yellow; - string vzStr = s.VerticalVelocity >= 0 - ? $"+{s.VerticalVelocity,4:F2}" - : $"{s.VerticalVelocity,5:F2}"; - lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor)); - - var nColor = s.Colliding ? Red : White; - string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m"; - lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor)); - - var cColor = s.Colliding ? Red : Green; - lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor)); - - if (s.InPlayerMode) - { - string orbitTag = s.RmbOrbit ? " [RMB orbit]" : ""; - lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}", - s.RmbOrbit ? Cyan : White)); - } - lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey)); - - DrawPanel(10f, 10f, lines); - } - - // ────────────────────────────────────────────────────────────────────── - // Stats panel — top-right: fps, frame time, landblock/entity counters. - // ────────────────────────────────────────────────────────────────────── - - private void DrawStatsPanel(Snapshot s, Vector2 screenSize) - { - var lines = new List<(string text, Vector4 color)> - { - ($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green), - ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White), - ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White), - ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White), - // Phase G: sky + weather + dynamic lighting surface. - ($"time {s.DayFraction,5:F2} {s.HourName}", Cyan), - ($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan), - ($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan), - }; - - float pad = 10f; - float panelW = MeasureMax(lines) + 2 * InnerPad; - DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW); - } - - // ────────────────────────────────────────────────────────────────────── - // Compass — bottom-center: arrow indicating heading, cardinal labels. - // ────────────────────────────────────────────────────────────────────── - - private void DrawCompass(Snapshot s, Vector2 screenSize) - { - // Simple linear compass strip across 180° of horizon at the top-center. - const float stripW = 360f; - const float stripH = 20f; - float cx = screenSize.X * 0.5f; - float top = 8f; - float left = cx - stripW * 0.5f; - - _text.DrawRect(left, top, stripW, stripH, PanelBg); - _text.DrawRectOutline(left, top, stripW, stripH, PanelBorder); - - // Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W. - // Heading 0 = +X (east). We show 180° wide, ±90° from current heading. - float h = NormalizeDeg(s.HeadingDeg); - for (int d = 0; d < 360; d += 15) - { - float rel = NormalizeDegSigned(d - h); - if (rel < -90 || rel > 90) continue; - float x = cx + rel / 90f * (stripW * 0.5f - 6f); - bool bold = (d % 90) == 0; - float tickH = bold ? stripH * 0.9f : stripH * 0.4f; - _text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey); - - if (bold) - { - string lab = d switch - { - 0 => "E", - 90 => "N", - 180 => "W", - 270 => "S", - _ => "" - }; - if (lab.Length > 0) - { - float w = _font.MeasureWidth(lab); - _text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White); - } - } - } - - // Current heading indicator arrow below the strip. - _text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow); - string hText = $"{h,3:F0}°"; - float hw = _font.MeasureWidth(hText); - _text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow); - } - - // ────────────────────────────────────────────────────────────────────── - // Help panel — center: full keybind cheat-sheet, shown when F1 is pressed. - // ────────────────────────────────────────────────────────────────────── - - private static readonly (string key, string desc)[] Keybinds = - { - ("F1", "toggle this help"), - ("F2", "toggle collision wireframes"), - ("F3", "console dump (pos + nearby objects)"), - ("F4", "toggle debug HUD info panel"), - ("F5", "toggle stats panel"), - ("F6", "toggle compass"), - ("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"), - ("F10", "cycle weather (clear/overcast/rain/snow/storm)"), - ("F", "toggle fly camera"), - ("Tab", "toggle player mode (requires login)"), - ("W A S D", "move (player mode) / fly"), - ("Mouse", "turn character / look (fly)"), - ("Hold RMB", "free orbit camera around player"), - ("Wheel", "zoom chase camera in / out"), - ("F8 / F9", "mouse sensitivity slower / faster"), - ("Space", "jump (hold to charge)"), - ("Shift", "run"), - ("Escape", "exit fly / player / close window"), - }; - - private void DrawHelpPanel(Vector2 screenSize) - { - var lines = new List<(string text, Vector4 color)>(); - lines.Add(("CONTROLS", Yellow)); - lines.Add(("", White)); - foreach (var (k, d) in Keybinds) - lines.Add(($" {k,-9} {d}", White)); - lines.Add(("", White)); - lines.Add(("Press F1 to close", Grey)); - - float panelW = MeasureMax(lines) + 2 * InnerPad; - float panelH = lines.Count * _font.LineHeight + 2 * InnerPad; - float x = (screenSize.X - panelW) * 0.5f; - float y = (screenSize.Y - panelH) * 0.5f; - DrawPanel(x, y, lines, panelW); - } - - // ────────────────────────────────────────────────────────────────────── - // Chat panel — bottom-left: live ChatLog tail (Phase H.1). - // Proves the F.1 GameEvent dispatcher → ChatLog pipeline is alive. - // ────────────────────────────────────────────────────────────────────── - - private void DrawChatPanel(Vector2 screenSize) - { - if (Chat is null) return; - const int TailSize = 10; - var snap = Chat.Snapshot(); - if (snap.Length == 0) return; - - var lines = new List<(string text, Vector4 color)>(); - int start = Math.Max(0, snap.Length - TailSize); - for (int i = start; i < snap.Length; i++) - { - var e = snap[i]; - string prefix = e.Kind switch - { - ChatKind.LocalSpeech => $"[say] {e.Sender}: ", - ChatKind.RangedSpeech => $"[shout] {e.Sender}: ", - ChatKind.Channel => $"[ch{e.ChannelId}] {e.Sender}: ", - ChatKind.Tell => $"[tell] {e.Sender}: ", - ChatKind.System => "* ", - ChatKind.Popup => "!! ", - _ => "", - }; - Vector4 color = e.Kind switch - { - ChatKind.Tell => Cyan, - ChatKind.Channel => Green, - ChatKind.System => Yellow, - ChatKind.Popup => Red, - _ => White, - }; - lines.Add(($"{prefix}{e.Text}", color)); - } - - // Render from bottom-up above the hint bar. - float panelW = Math.Max(520f, MeasureMax(lines) + 2 * InnerPad); - float panelH = lines.Count * _font.LineHeight + 2 * InnerPad; - float x = 10f; - float y = screenSize.Y - _font.LineHeight - 18f - panelH; - DrawPanel(x, y, lines, panelW); - } - - // ────────────────────────────────────────────────────────────────────── - // Event panel — bottom-right: combat damage + evasion log (Phase E.4). - // Each entry fades as its timer runs out. - // ────────────────────────────────────────────────────────────────────── - - private void DrawEventPanel(Vector2 screenSize) - { - if (_eventTail.Count == 0) return; - - float lineH = _font.LineHeight; - float panelW = 360f; - // Prepare fade-out per line. - float x = screenSize.X - panelW - 10f; - float panelH = _eventTail.Count * lineH + 2 * InnerPad; - float y = screenSize.Y - _font.LineHeight - 18f - panelH; - - _text.DrawRect(x, y, panelW, panelH, PanelBg); - _text.DrawRectOutline(x, y, panelW, panelH, PanelBorder); - - float cy = y + InnerPad; - for (int i = 0; i < _eventTail.Count; i++) - { - var e = _eventTail[i]; - var c = e.color; - c.W *= MathF.Min(1f, e.timeLeft / 1.5f); - _text.DrawString(_font, e.text, x + InnerPad, cy, c); - cy += lineH; - } - } - - // ────────────────────────────────────────────────────────────────────── - // Hint bar — bottom-left: always-visible "F1 for help" reminder. - // ────────────────────────────────────────────────────────────────────── - - private void DrawHintBar(Vector2 screenSize) - { - string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom"; - float w = _font.MeasureWidth(hint); - float pad = 10f; - float y = screenSize.Y - _font.LineHeight - pad; - _text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg); - _text.DrawString(_font, hint, pad, y, Grey); - } - - private void DrawToast(Vector2 screenSize) - { - if (_toastText is null || _toastTimeLeft <= 0f) return; - float w = _font.MeasureWidth(_toastText); - float x = (screenSize.X - w) * 0.5f; - float y = 60f; - var c = _toastColor; - float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f); - c.W *= alpha; - var bg = PanelBg; - bg.W *= alpha; - _text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg); - _text.DrawString(_font, _toastText, x, y, c); - } - - // ────────────────────────────────────────────────────────────────────── - // Panel helpers. - // ────────────────────────────────────────────────────────────────────── - - private const float InnerPad = 8f; - - private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines) - { - float m = 0; - foreach (var (text, _) in lines) - m = MathF.Max(m, _font.MeasureWidth(text)); - return m; - } - - private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null) - { - float maxW = MeasureMax(lines); - float panelW = widthOverride ?? (maxW + 2 * InnerPad); - float panelH = lines.Count * _font.LineHeight + 2 * InnerPad; - - _text.DrawRect(x, y, panelW, panelH, PanelBg); - _text.DrawRectOutline(x, y, panelW, panelH, PanelBorder); - - float cy = y + InnerPad; - foreach (var (text, color) in lines) - { - _text.DrawString(_font, text, x + InnerPad, cy, color); - cy += _font.LineHeight; - } - } - - private static float NormalizeDeg(float deg) - { - deg %= 360f; - if (deg < 0) deg += 360f; - return deg; - } - - private static float NormalizeDegSigned(float deg) - { - deg %= 360f; - if (deg > 180) deg -= 360f; - if (deg < -180) deg += 360f; - return deg; - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ac201d6..d5428eb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -30,18 +30,29 @@ public sealed class GameWindow : IDisposable private bool _debugCollisionVisible = true; private int _debugDrawLogOnce = 0; - // On-screen debug HUD — info panel, stats panel, compass, keybind help. - // F1/F2/F4/F5/F6 toggle the individual panels (see the key handler). - // Null if no system font is available at startup; in that case the HUD - // is silently disabled and the rest of the client keeps working. + // Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in + // favor of the ImGui-backed DebugPanel (see _debugVm below). The + // TextRenderer + BitmapFont fields stay alive because they're shared + // with UiHost and reserved for the future world-space HUD (D.6 — + // damage floaters, name plates) where ImGui can't reach into the 3D + // scene. They are no longer used for any debug overlay. private TextRenderer? _textRenderer; private BitmapFont? _debugFont; - private DebugOverlay? _debugOverlay; // Last-computed perf values so the HUD always has something to show even // though the title-bar FPS is only updated every 0.5s. private double _lastFps = 60.0; private double _lastFrameMs = 16.7; + // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel + // VM closures. Computed once per render pass alongside the frustum + // walk + nearest-object scan; the VM closures just read the cached + // values. Skipped when DevTools are off (zero cost). + private int _lastVisibleLandblocks; + private int _lastTotalLandblocks; + private float _lastNearestObjDist = float.PositiveInfinity; + private string _lastNearestObjLabel = "-"; + private bool _lastColliding; + // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private readonly AcDream.App.Streaming.GpuWorldState _worldState = new(); @@ -301,6 +312,10 @@ public sealed class GameWindow : IDisposable private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap; private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; + // Phase I.2: ImGui debug panel ViewModel. Lives for as long as + // _panelHost does. Self-subscribes to CombatState in its ctor, so + // disposing isn't required (panel host holds the only ref). + private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; private static readonly bool DevToolsEnabled = Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1"; @@ -537,85 +552,34 @@ public sealed class GameWindow : IDisposable } else if (key == Key.F1) { - if (_debugOverlay is not null) + // Phase I.2: F1 now toggles the entire ImGui DebugPanel + // visibility. The old per-section toggles (F4/F5/F6) are + // gone — sections are collapsing headers inside the + // single window now. + foreach (var panel in EnumerateDebugPanel()) { - _debugOverlay.ShowHelpPanel = !_debugOverlay.ShowHelpPanel; - _debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}"); + panel.IsVisible = !panel.IsVisible; + _debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}"); } } else if (key == Key.F2) { - _debugCollisionVisible = !_debugCollisionVisible; - _debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); - } - else if (key == Key.F4) - { - if (_debugOverlay is not null) - { - _debugOverlay.ShowInfoPanel = !_debugOverlay.ShowInfoPanel; - _debugOverlay.Toast($"Info panel {(_debugOverlay.ShowInfoPanel ? "ON" : "OFF")}"); - } - } - else if (key == Key.F5) - { - if (_debugOverlay is not null) - { - _debugOverlay.ShowStatsPanel = !_debugOverlay.ShowStatsPanel; - _debugOverlay.Toast($"Stats panel {(_debugOverlay.ShowStatsPanel ? "ON" : "OFF")}"); - } - } - else if (key == Key.F6) - { - if (_debugOverlay is not null) - { - _debugOverlay.ShowCompass = !_debugOverlay.ShowCompass; - _debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}"); - } + // Real gameplay toggle — keeps the F2 keybind. Same + // action is wired into the DebugPanel's + // "Toggle collision wires" button via DebugVM. + ToggleCollisionWires(); } else if (key == Key.F7) { - // Phase G.1: cycle debug time-of-day overrides. Useful for - // visually verifying the sun arc + keyframe transitions - // without waiting 30+ real-time hours. Cycle order: - // clear debug → 0.0 (midnight) → 0.25 (dawn) - // → 0.5 (noon) → 0.75 (dusk) → clear - _timeDebugStep = (_timeDebugStep + 1) % 5; - float? pick = _timeDebugStep switch - { - 0 => (float?)null, // server time - 1 => 0.0f, - 2 => 0.25f, - 3 => 0.5f, - 4 => 0.75f, - _ => null, - }; - if (pick.HasValue) - { - WorldTime.SetDebugTime(pick.Value); - _debugOverlay?.Toast($"Time override = {pick.Value:F2}"); - } - else - { - WorldTime.ClearDebugTime(); - _debugOverlay?.Toast("Time override cleared"); - } + // Phase I.2: keep F7 as a hotkey alias for the + // DebugPanel's "Cycle time of day" button. + CycleTimeOfDay(); } else if (key == Key.F10) { - // Phase G.1: cycle weather kinds manually. Useful for - // testing the rain/snow particle systems + storm/light - // fog without waiting for the daily RNG to hit. - var kinds = new[] - { - AcDream.Core.World.WeatherKind.Clear, - AcDream.Core.World.WeatherKind.Overcast, - AcDream.Core.World.WeatherKind.Rain, - AcDream.Core.World.WeatherKind.Snow, - AcDream.Core.World.WeatherKind.Storm, - }; - _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; - Weather.ForceWeather(kinds[_weatherDebugStep]); - _debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}"); + // Phase I.2: keep F10 as a hotkey alias for the + // DebugPanel's "Cycle weather" button. + CycleWeather(); } else if (key == Key.F8 || key == Key.F9) { @@ -638,7 +602,7 @@ public sealed class GameWindow : IDisposable else if (modeLabel == "Fly") _sensFly = next; else _sensOrbit = next; - _debugOverlay?.Toast($"{modeLabel} sens {next:F3}x"); + _debugVm?.AddToast($"{modeLabel} sens {next:F3}x"); } else if (key == Key.Escape) { @@ -857,25 +821,23 @@ public sealed class GameWindow : IDisposable _debugLines = new DebugLineRenderer(_gl, shadersDir); - // Debug HUD: load a system monospace font and set up the text overlay. - // Skips silently if no font is available (the rest of the client still works). + // Phase I.2: load a system monospace font + TextRenderer for the + // future world-space HUD (D.6). The custom DebugOverlay is gone; + // the ImGui DebugPanel handles all dev surfaces now. These fields + // are reserved for future work — currently unused at the renderer + // level. Skips silently if no font is available. var fontBytes = BitmapFont.TryLoadSystemMonospaceFont(); if (fontBytes is not null) { _debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512); _textRenderer = new TextRenderer(_gl, shadersDir); - _debugOverlay = new DebugOverlay(_textRenderer, _debugFont); - // Phase F.1/H.1/E.4 visibility: show chat + combat events on screen. - _debugOverlay.Chat = Chat; - _debugOverlay.Combat = Combat; - _debugOverlay.BindCombat(Combat); - Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " + + Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " + $"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " + - $"lineHeight={_debugFont.LineHeight:F1}px"); + $"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)"); } else { - Console.WriteLine("debug overlay: no system monospace font found; HUD disabled"); + Console.WriteLine("world-hud font: no system monospace font found"); } var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; @@ -959,7 +921,50 @@ public sealed class GameWindow : IDisposable _panelHost.Register( new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm)); - Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel registered)"); + // 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: () => _lastVisibleLandblocks, + getLandblocksTotal: () => _lastTotalLandblocks, + getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, + getNearestObjDist: () => _lastNearestObjDist, + getNearestObjLabel: () => _lastNearestObjLabel, + getColliding: () => _lastColliding, + getDebugWireframes: () => _debugCollisionVisible, + getStreamingRadius: () => _streamingRadius, + getMouseSensitivity: () => GetActiveSensitivity(), + getChaseDistance: () => _chaseCamera?.Distance ?? 0f, + getRmbOrbit: () => _rmbHeld, + 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)_lastFps, + getFrameMs: () => (float)_lastFrameMs, + combat: Combat); + _debugVm.CycleTimeOfDay = CycleTimeOfDay; + _debugVm.CycleWeather = CycleWeather; + _debugVm.ToggleCollisionWires = ToggleCollisionWires; + _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); + _panelHost.Register(_debugPanel); + + Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel registered)"); } catch (Exception ex) { @@ -968,6 +973,8 @@ public sealed class GameWindow : IDisposable _imguiBootstrap = null; _panelHost = null; _vitalsVm = null; + _debugVm = null; + _debugPanel = null; } } @@ -4119,48 +4126,32 @@ public sealed class GameWindow : IDisposable visibleLandblocks++; } - // ── Debug HUD overlay ──────────────────────────────────────────── - // Build a per-frame snapshot of state we want to show and hand it - // to the overlay. Drawn after all 3D passes so it sits on top. - if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null) + // Phase I.2: refresh per-frame fields that DebugVM closures + // can't compute lazily (frustum-derived counters + nearest- + // object scan). Every other DebugVM field reads through to + // the live source via its closure. Skipped entirely when + // devtools are off — avoids the nearest-object O(N) scan in + // the hot path of an offline render. + if (_debugVm is not null) { - System.Numerics.Vector3 playerPos; - float headingDeg; - uint cellId; - bool onGround; - float vVel; - if (_playerMode && _playerController is not null) - { - playerPos = _playerController.Position; - // Yaw in math convention: 0 = +X east, PI/2 = +Y north. - // Convert to degrees in [0, 360). - headingDeg = _playerController.Yaw * (180f / MathF.PI); - headingDeg %= 360f; - if (headingDeg < 0f) headingDeg += 360f; - cellId = _playerController.CellId; - onGround = !_playerController.IsAirborne; - vVel = _playerController.VerticalVelocity; - } - else - { - playerPos = camPos; - var camFwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); - headingDeg = MathF.Atan2(camFwd.Y, camFwd.X) * (180f / MathF.PI); - if (headingDeg < 0f) headingDeg += 360f; - cellId = 0u; - onGround = false; - vVel = 0f; - } + _lastVisibleLandblocks = visibleLandblocks; + _lastTotalLandblocks = totalLandblocks; + + // Compute fly/orbit-mode camera position for the nearest- + // object scan when not in player mode. + System.Numerics.Vector3 nearOrigin; + if (_playerMode && _playerController is not null) + nearOrigin = _playerController.Position; + else + nearOrigin = camPos; - // Nearest shadow object — surface-to-surface distance in XY - // (subtract player radius + obj radius). Negative == penetrating. const float playerRadius = 0.48f; float bestDist = float.PositiveInfinity; string bestLabel = "-"; foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) { - float dx = obj.Position.X - playerPos.X; - float dy = obj.Position.Y - playerPos.Y; + float dx = obj.Position.X - nearOrigin.X; + float dy = obj.Position.Y - nearOrigin.Y; float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius; if (d < bestDist) { @@ -4168,53 +4159,9 @@ public sealed class GameWindow : IDisposable bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}"; } } - bool colliding = bestDist < 0.05f; - if (bestDist < 0f) bestDist = 0f; - - // Select the active-mode sensitivity to display. - float activeSens; - if (_playerMode && _cameraController?.IsChaseMode == true) - activeSens = _sensChase; - else if (_cameraController?.IsFlyMode == true) - activeSens = _sensFly; - else - activeSens = _sensOrbit; - - // Phase G: pull sky + weather + lighting state for the overlay. - var dayCal = WorldTime.CurrentCalendar; - var snapshot = new DebugOverlay.Snapshot( - Fps: (float)_lastFps, - FrameTimeMs: (float)_lastFrameMs, - PlayerPos: playerPos, - HeadingDeg: headingDeg, - CellId: cellId, - OnGround: onGround, - InPlayerMode: _playerMode, - InFlyMode: _cameraController?.IsFlyMode ?? false, - VerticalVelocity: vVel, - EntityCount: _worldState.Entities.Count, - AnimatedCount: _animatedEntities.Count, - LandblocksVisible: visibleLandblocks, - LandblocksTotal: totalLandblocks, - ShadowObjectCount: _physicsEngine.ShadowObjects.TotalRegistered, - NearestObjDist: bestDist, - NearestObjLabel: bestLabel, - Colliding: colliding, - DebugWireframes: _debugCollisionVisible, - StreamingRadius: _streamingRadius, - MouseSensitivity: activeSens, - ChaseDistance: _chaseCamera?.Distance ?? 0f, - RmbOrbit: _rmbHeld, - HourName: dayCal.Hour.ToString(), - DayFraction: (float)WorldTime.DayFraction, - Weather: Weather.Kind.ToString(), - ActiveLights: Lighting.ActiveCount, - RegisteredLights: Lighting.RegisteredCount, - ParticleCount: _particleSystem?.ActiveParticleCount ?? 0); - - _debugOverlay.Update((float)deltaSeconds); - var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y); - _debugOverlay.Draw(snapshot, size); + _lastColliding = bestDist < 0.05f; + _lastNearestObjDist = bestDist < 0f ? 0f : bestDist; + _lastNearestObjLabel = bestLabel; } } @@ -4939,6 +4886,146 @@ public sealed class GameWindow : IDisposable EndSize = 0.06f, }; + // ── 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. + + /// Player-mode-aware position source for the DebugPanel. + 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; + } + + /// Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise. + 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() => + _playerMode && _playerController is not null && !_playerController.IsAirborne; + + private float GetActiveSensitivity() + { + if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase; + if (_cameraController?.IsFlyMode == true) return _sensFly; + return _sensOrbit; + } + + /// + /// Cycle the time-of-day debug override. Same body as the old F7 + /// keybind handler; called by both the keybind AND the DebugPanel + /// "Cycle time of day" button via DebugVM.CycleTimeOfDay. + /// + private void CycleTimeOfDay() + { + // none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none + _timeDebugStep = (_timeDebugStep + 1) % 5; + float? pick = _timeDebugStep switch + { + 0 => (float?)null, + 1 => 0.0f, + 2 => 0.25f, + 3 => 0.5f, + 4 => 0.75f, + _ => null, + }; + if (pick.HasValue) + { + WorldTime.SetDebugTime(pick.Value); + _debugVm?.AddToast($"Time override = {pick.Value:F2}"); + } + else + { + WorldTime.ClearDebugTime(); + _debugVm?.AddToast("Time override cleared"); + } + } + + /// + /// Cycle the weather kind. Same body as the old F10 keybind handler. + /// + private void CycleWeather() + { + var kinds = new[] + { + AcDream.Core.World.WeatherKind.Clear, + AcDream.Core.World.WeatherKind.Overcast, + AcDream.Core.World.WeatherKind.Rain, + AcDream.Core.World.WeatherKind.Snow, + AcDream.Core.World.WeatherKind.Storm, + }; + _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; + Weather.ForceWeather(kinds[_weatherDebugStep]); + _debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}"); + } + + /// + /// Toggle the collision-wires debug renderer. Same body as the old + /// F2 keybind handler. + /// + private void ToggleCollisionWires() + { + _debugCollisionVisible = !_debugCollisionVisible; + _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); + } + + /// + /// Yields the registered DebugPanel(s) so F1 can flip their + /// visibility. Returns nothing when devtools are off. + /// + private IEnumerable EnumerateDebugPanel() + { + // The current ImGuiPanelHost only exposes Register/Unregister, + // not enumerate. We track the DebugPanel through the VM presence + // — the panel is registered iff _debugVm is non-null. Look it + // up via the panel ID convention. + // Defer the actual lookup to the panel host once it grows an + // accessor; for now, no-op when devtools are off. + if (_debugPanel is not null) yield return _debugPanel; + } + + // Cached panel reference so EnumerateDebugPanel can return it. Set + // in the DevToolsEnabled construction block above; null otherwise. + private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel; + private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs new file mode 100644 index 0000000..076faa2 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -0,0 +1,250 @@ +using System.Numerics; + +namespace AcDream.UI.Abstractions.Panels.Debug; + +/// +/// The Phase I.2 debug panel — single ImGui window with collapsing-header +/// sections that replace the old custom DebugOverlay's six floating +/// panels (Info / Stats / Help / Compass / Chat / Event) plus the toast +/// surface. Reads through so values are always live. +/// +/// +/// Layout: Player Info, Performance, Compass, Help, Combat events, Recent +/// toasts, Diagnostics. Each section is a CollapsingHeader; +/// importance-ranked sections default open, niche ones default closed. +/// +/// +/// +/// Reuses the I.1 widget extensions only; never imports a backend +/// namespace. Same constraints as VitalsPanel and ChatPanel. +/// +/// +public sealed class DebugPanel : IPanel +{ + private readonly DebugVM _vm; + + public DebugPanel(DebugVM vm) + { + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); + } + + /// + public string Id => "acdream.debug"; + + /// + public string Title => "Debug"; + + /// + public bool IsVisible { get; set; } = true; + + /// + /// Cheat-sheet of currently meaningful keybinds. Kept as a static + /// table because the data is stable and the panel only renders + /// labels — no behavior change to the bindings themselves. + /// + private static readonly (string Key, string Action)[] Keybinds = + { + ("Esc", "exit fly / player / close window"), + ("Tab", "toggle player mode (when in-world)"), + ("F", "toggle fly camera"), + ("F1", "toggle this debug panel"), + ("F2", "toggle collision wireframes"), + ("F3", "console dump (pos + nearby objects)"), + ("F7", "cycle time-of-day override"), + ("F8 / F9", "mouse sensitivity slower / faster"), + ("F10", "cycle weather"), + ("W A S D", "move (player mode) / fly"), + ("Mouse", "turn character / look (fly)"), + ("Hold RMB", "free orbit camera around player"), + ("Wheel", "zoom chase camera in / out"), + ("Space", "jump"), + ("Shift", "run"), + }; + + /// + public void Render(PanelContext ctx, IPanelRenderer renderer) + { + if (!renderer.Begin(Title)) + { + renderer.End(); + return; + } + + DrawPlayerInfo(renderer); + DrawPerformance(renderer); + DrawCompass(renderer); + DrawHelp(renderer); + DrawCombatEvents(renderer); + DrawRecentToasts(renderer); + DrawDiagnostics(renderer); + + renderer.End(); + } + + // ── Sections ────────────────────────────────────────────────────── + + private void DrawPlayerInfo(IPanelRenderer r) + { + if (!r.CollapsingHeader("Player Info", defaultOpen: true)) return; + + string mode = _vm.InPlayerMode ? "PLAYER" + : _vm.InFlyMode ? "FLY" + : "ORBIT"; + r.Text($"mode: {mode} cell: 0x{_vm.CellId:X8}"); + var p = _vm.PlayerPosition; + r.Text($"pos: ({p.X,7:F1}, {p.Y,7:F1}, {p.Z,7:F2})"); + r.Text($"heading: {_vm.HeadingDeg,3:F0}°"); + r.Text($"grounded: {(_vm.OnGround ? "yes" : "no ")} vZ: {_vm.VerticalVelocity,5:F2}"); + + string near = float.IsPositiveInfinity(_vm.NearestObjDist) + ? "---" + : $"{_vm.NearestObjDist,4:F1}m"; + if (_vm.Colliding) + { + r.TextColored(new Vector4(1f, 0.4f, 0.35f, 1f), + $"near: {near} {_vm.NearestObjLabel} [BLOCKED]"); + } + else + { + r.Text($"near: {near} {_vm.NearestObjLabel}"); + } + + if (_vm.InPlayerMode) + r.Text($"chase dist: {_vm.ChaseDistance,4:F1}m{(_vm.RmbOrbit ? " [RMB orbit]" : "")}"); + r.Text($"sens: {_vm.MouseSensitivity:F3}x"); + } + + private void DrawPerformance(IPanelRenderer r) + { + if (!r.CollapsingHeader("Performance", defaultOpen: true)) return; + + r.Text($"fps: {_vm.Fps,5:F0} frame: {_vm.FrameMs,5:F1} ms"); + r.Text($"visible LB: {_vm.LandblocksVisible,3}/{_vm.LandblocksTotal,3} radius: {_vm.StreamingRadius}"); + r.Text($"entities: {_vm.EntityCount,4} animated: {_vm.AnimatedCount,3} coll: {_vm.ShadowObjectCount}"); + r.Text($"lights: {_vm.ActiveLights}/{_vm.RegisteredLights} particles: {_vm.ParticleCount}"); + r.Text($"time: {_vm.DayFraction,5:F2} {_vm.HourName} weather: {_vm.Weather}"); + } + + private void DrawCompass(IPanelRenderer r) + { + if (!r.CollapsingHeader("Compass", defaultOpen: false)) return; + + // Phase I.2 stub — the visual strip + cardinal markers from the + // old DebugOverlay relied on raw 2D-rect primitives we don't (and + // shouldn't) expose through IPanelRenderer. The fancy compass + // strip lands in D.6 with proper world-HUD draw-list primitives. + // For now show heading degrees + compass cardinal label. + float h = NormalizeDeg(_vm.HeadingDeg); + r.Text($"heading: {h,3:F0}° cardinal: {Cardinal(h)}"); + } + + private void DrawHelp(IPanelRenderer r) + { + if (!r.CollapsingHeader("Help", defaultOpen: false)) return; + + r.BeginTable("debug.help", 2); + foreach (var (key, action) in Keybinds) + { + r.TableNextColumn(); + r.Text(key); + r.TableNextColumn(); + r.Text(action); + } + r.EndTable(); + } + + private void DrawCombatEvents(IPanelRenderer r) + { + if (!r.CollapsingHeader("Combat events", defaultOpen: true)) return; + + if (_vm.CombatEvents.Count == 0) + { + r.Text("(no recent combat)"); + return; + } + + foreach (var line in _vm.CombatEvents) + { + r.TextColored(ColorForCombat(line.Kind), line.Text); + } + } + + private void DrawRecentToasts(IPanelRenderer r) + { + if (!r.CollapsingHeader("Recent toasts", defaultOpen: false)) return; + + if (_vm.RecentToasts.Count == 0) + { + r.Text("(none)"); + return; + } + + foreach (var t in _vm.RecentToasts) + { + string ts = t.Timestamp.ToLocalTime().ToString("HH:mm:ss"); + r.TextColored(ColorForToast(t.Kind), $"[{ts}] {t.Text}"); + } + } + + private void DrawDiagnostics(IPanelRenderer r) + { + if (!r.CollapsingHeader("Diagnostics", defaultOpen: true)) return; + + bool dumpMotion = _vm.DumpMotion; + bool dumpVitals = _vm.DumpVitals; + bool dumpOpcodes = _vm.DumpOpcodes; + bool dumpSky = _vm.DumpSky; + + if (r.Checkbox("Dump motion (ACDREAM_DUMP_MOTION)", ref dumpMotion)) _vm.DumpMotion = dumpMotion; + if (r.Checkbox("Dump vitals (ACDREAM_DUMP_VITALS)", ref dumpVitals)) _vm.DumpVitals = dumpVitals; + if (r.Checkbox("Dump opcodes (ACDREAM_DUMP_OPCODES)", ref dumpOpcodes)) _vm.DumpOpcodes = dumpOpcodes; + if (r.Checkbox("Dump sky (ACDREAM_DUMP_SKY)", ref dumpSky)) _vm.DumpSky = dumpSky; + + r.Spacing(); + + // Cycle / toggle actions live on the VM as Action handles; the + // host (GameWindow) populates them with the same lambdas the + // old F7/F10/F2 keybinds used. + if (r.Button("Cycle time of day")) _vm.CycleTimeOfDay?.Invoke(); + r.SameLine(); + if (r.Button("Cycle weather")) _vm.CycleWeather?.Invoke(); + r.SameLine(); + if (r.Button("Toggle collision wires")) _vm.ToggleCollisionWires?.Invoke(); + + r.Text(_vm.DebugWireframes ? "collision wires: ON" : "collision wires: OFF"); + } + + // ── Color helpers ───────────────────────────────────────────────── + + private static Vector4 ColorForCombat(CombatEventKind kind) => kind switch + { + CombatEventKind.Info => new Vector4(1.0f, 0.9f, 0.3f, 1f), // yellow + CombatEventKind.Warn => new Vector4(1.0f, 0.5f, 0.5f, 1f), // light red + CombatEventKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1f), // deep red + _ => new Vector4(1f, 1f, 1f, 1f), + }; + + private static Vector4 ColorForToast(ToastKind kind) => kind switch + { + ToastKind.Warn => new Vector4(1.0f, 0.8f, 0.4f, 1f), + ToastKind.Error => new Vector4(1.0f, 0.4f, 0.4f, 1f), + _ => new Vector4(0.85f, 0.95f, 1.0f, 1f), + }; + + private static float NormalizeDeg(float deg) + { + deg %= 360f; + if (deg < 0) deg += 360f; + return deg; + } + + private static string Cardinal(float deg) + { + // Heading 0 = +X (east) per the old overlay. Same eight cardinal + // labels — N/E/S/W with NE/SE/SW/NW between. + // 0=E, 90=N, 180=W, 270=S (acdream's coordinate convention). + string[] dirs = { "E", "NE", "N", "NW", "W", "SW", "S", "SE" }; + int idx = (int)MathF.Round(deg / 45f) & 7; + return dirs[idx]; + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs new file mode 100644 index 0000000..7765f95 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using AcDream.Core.Combat; + +namespace AcDream.UI.Abstractions.Panels.Debug; + +/// +/// Severity tag for a single combat-event line in the +/// ring. The panel reads this to pick +/// a TextColored rgba per row (yellow info / red warn / deep-red +/// error). Mirrors the same tri-tone the chat panel uses for combat +/// (Phase I.7). +/// +public enum CombatEventKind +{ + /// You dealt damage / landed a hit. Yellow. + Info, + /// An incoming hit you evaded. Red. + Warn, + /// You took damage. Deep red. + Error, +} + +/// +/// Single typed entry in the combat-events ring. +/// is captured at append time so a future panel revision can fade old +/// entries; for I.2 the panel just renders the text + rgba. +/// +public readonly record struct CombatEventLine( + DateTime Timestamp, + CombatEventKind Kind, + string Text); + +/// +/// Severity tag for a transient toast message. Mirrors +/// but lives in its own enum so the toast +/// surface can grow (e.g. an "OK" green) without dragging the combat +/// surface along. +/// +public enum ToastKind +{ + Info, + Warn, + Error, +} + +/// Single transient toast message kept in the recent-toasts ring. +public readonly record struct ToastMessage( + DateTime Timestamp, + ToastKind Kind, + string Text); + +/// +/// ViewModel for the Phase I.2 . Read-through +/// (no caching): every property forwards to a Func<T> that +/// the host (GameWindow) wires up at construction. Internal +/// state is limited to (a) the combat-event ring buffer, populated via a +/// self-subscription to 's typed events +/// (replacing the old DebugOverlay.BindCombat); (b) the toast +/// ring; (c) the diagnostic-flag bools the panel exposes as checkboxes. +/// +/// +/// Constructor explosion is intentional and acceptable here — the VM +/// lives entirely inside the AcDream.App composition root, not in any +/// plugin-facing surface. A nicer abstraction can come later if more +/// debug panels appear. +/// +/// +public sealed class DebugVM +{ + /// Maximum number of combat-event lines kept in the ring. + public const int MaxCombatEvents = 25; + + /// Maximum number of recent toast messages kept in the ring. + public const int MaxRecentToasts = 25; + + private readonly Func _getPlayerPosition; + private readonly Func _getPlayerHeadingDeg; + private readonly Func _getPlayerCellId; + private readonly Func _getPlayerOnGround; + private readonly Func _getInPlayerMode; + private readonly Func _getInFlyMode; + private readonly Func _getVerticalVelocity; + private readonly Func _getEntityCount; + private readonly Func _getAnimatedCount; + private readonly Func _getLandblocksVisible; + private readonly Func _getLandblocksTotal; + private readonly Func _getShadowObjectCount; + private readonly Func _getNearestObjDist; + private readonly Func _getNearestObjLabel; + private readonly Func _getColliding; + private readonly Func _getDebugWireframes; + private readonly Func _getStreamingRadius; + private readonly Func _getMouseSensitivity; + private readonly Func _getChaseDistance; + private readonly Func _getRmbOrbit; + private readonly Func _getHourName; + private readonly Func _getDayFraction; + private readonly Func _getWeather; + private readonly Func _getActiveLights; + private readonly Func _getRegisteredLights; + private readonly Func _getParticleCount; + private readonly Func _getFps; + private readonly Func _getFrameMs; + + private readonly Queue _combatEvents = new(); + private readonly Queue _toasts = new(); + + /// + /// Build a VM bound to live data sources. Every Func is read + /// per-frame by the panel — pass closures that resolve to the + /// authoritative source on each call so the panel always sees fresh + /// state. + /// + public DebugVM( + Func getPlayerPosition, + Func getPlayerHeadingDeg, + Func getPlayerCellId, + Func getPlayerOnGround, + Func getInPlayerMode, + Func getInFlyMode, + Func getVerticalVelocity, + Func getEntityCount, + Func getAnimatedCount, + Func getLandblocksVisible, + Func getLandblocksTotal, + Func getShadowObjectCount, + Func getNearestObjDist, + Func getNearestObjLabel, + Func getColliding, + Func getDebugWireframes, + Func getStreamingRadius, + Func getMouseSensitivity, + Func getChaseDistance, + Func getRmbOrbit, + Func getHourName, + Func getDayFraction, + Func getWeather, + Func getActiveLights, + Func getRegisteredLights, + Func getParticleCount, + Func getFps, + Func getFrameMs, + CombatState combat) + { + if (combat is null) throw new ArgumentNullException(nameof(combat)); + _getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition)); + _getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg)); + _getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId)); + _getPlayerOnGround = getPlayerOnGround ?? throw new ArgumentNullException(nameof(getPlayerOnGround)); + _getInPlayerMode = getInPlayerMode ?? throw new ArgumentNullException(nameof(getInPlayerMode)); + _getInFlyMode = getInFlyMode ?? throw new ArgumentNullException(nameof(getInFlyMode)); + _getVerticalVelocity = getVerticalVelocity ?? throw new ArgumentNullException(nameof(getVerticalVelocity)); + _getEntityCount = getEntityCount ?? throw new ArgumentNullException(nameof(getEntityCount)); + _getAnimatedCount = getAnimatedCount ?? throw new ArgumentNullException(nameof(getAnimatedCount)); + _getLandblocksVisible = getLandblocksVisible ?? throw new ArgumentNullException(nameof(getLandblocksVisible)); + _getLandblocksTotal = getLandblocksTotal ?? throw new ArgumentNullException(nameof(getLandblocksTotal)); + _getShadowObjectCount = getShadowObjectCount ?? throw new ArgumentNullException(nameof(getShadowObjectCount)); + _getNearestObjDist = getNearestObjDist ?? throw new ArgumentNullException(nameof(getNearestObjDist)); + _getNearestObjLabel = getNearestObjLabel ?? throw new ArgumentNullException(nameof(getNearestObjLabel)); + _getColliding = getColliding ?? throw new ArgumentNullException(nameof(getColliding)); + _getDebugWireframes = getDebugWireframes ?? throw new ArgumentNullException(nameof(getDebugWireframes)); + _getStreamingRadius = getStreamingRadius ?? throw new ArgumentNullException(nameof(getStreamingRadius)); + _getMouseSensitivity = getMouseSensitivity ?? throw new ArgumentNullException(nameof(getMouseSensitivity)); + _getChaseDistance = getChaseDistance ?? throw new ArgumentNullException(nameof(getChaseDistance)); + _getRmbOrbit = getRmbOrbit ?? throw new ArgumentNullException(nameof(getRmbOrbit)); + _getHourName = getHourName ?? throw new ArgumentNullException(nameof(getHourName)); + _getDayFraction = getDayFraction ?? throw new ArgumentNullException(nameof(getDayFraction)); + _getWeather = getWeather ?? throw new ArgumentNullException(nameof(getWeather)); + _getActiveLights = getActiveLights ?? throw new ArgumentNullException(nameof(getActiveLights)); + _getRegisteredLights = getRegisteredLights ?? throw new ArgumentNullException(nameof(getRegisteredLights)); + _getParticleCount = getParticleCount ?? throw new ArgumentNullException(nameof(getParticleCount)); + _getFps = getFps ?? throw new ArgumentNullException(nameof(getFps)); + _getFrameMs = getFrameMs ?? throw new ArgumentNullException(nameof(getFrameMs)); + + // Self-subscribe to combat events. Each one becomes a typed entry + // in the ring; the panel renders them in TextColored. Replaces + // the old DebugOverlay.BindCombat side-channel. + combat.DamageTaken += d => Push(CombatEventKind.Error, + $"<< {d.AttackerName} hit you for {d.Damage}{(d.Critical ? " CRIT!" : "")}"); + combat.DamageDealtAccepted += d => Push(CombatEventKind.Info, + $">> you hit {d.DefenderName} for {d.Damage}"); + combat.EvadedIncoming += attacker => Push(CombatEventKind.Warn, + $"<< {attacker}'s attack missed you"); + 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 ─────────────────────────────────── + + public Vector3 PlayerPosition => _getPlayerPosition(); + public float HeadingDeg => _getPlayerHeadingDeg(); + public uint CellId => _getPlayerCellId(); + public bool OnGround => _getPlayerOnGround(); + public bool InPlayerMode => _getInPlayerMode(); + public bool InFlyMode => _getInFlyMode(); + public float VerticalVelocity => _getVerticalVelocity(); + public int EntityCount => _getEntityCount(); + public int AnimatedCount => _getAnimatedCount(); + public int LandblocksVisible => _getLandblocksVisible(); + public int LandblocksTotal => _getLandblocksTotal(); + public int ShadowObjectCount => _getShadowObjectCount(); + public float NearestObjDist => _getNearestObjDist(); + public string NearestObjLabel => _getNearestObjLabel(); + public bool Colliding => _getColliding(); + public bool DebugWireframes => _getDebugWireframes(); + public int StreamingRadius => _getStreamingRadius(); + public float MouseSensitivity => _getMouseSensitivity(); + public float ChaseDistance => _getChaseDistance(); + public bool RmbOrbit => _getRmbOrbit(); + public string HourName => _getHourName(); + public float DayFraction => _getDayFraction(); + public string Weather => _getWeather(); + public int ActiveLights => _getActiveLights(); + public int RegisteredLights => _getRegisteredLights(); + public int ParticleCount => _getParticleCount(); + public float Fps => _getFps(); + public float FrameMs => _getFrameMs(); + + // ── Diagnostic toggles (env-var-style runtime flags) ─────────────── + + /// Mirror of ACDREAM_DUMP_MOTION; flipped at runtime via the panel. + public bool DumpMotion { get; set; } + /// Mirror of ACDREAM_DUMP_VITALS. + public bool DumpVitals { get; set; } + /// Mirror of ACDREAM_DUMP_OPCODES. + public bool DumpOpcodes { get; set; } + /// Mirror of ACDREAM_DUMP_SKY. + public bool DumpSky { get; set; } + + // ── Action hooks invoked by panel buttons ────────────────────────── + + /// + /// Cycle the time-of-day debug override (matches the old F7 + /// behavior — none → midnight → dawn → noon → dusk → none). Wired + /// by GameWindow; null when no host is available (tests). + /// + public Action? CycleTimeOfDay { get; set; } + + /// + /// Cycle the weather-kind debug override (matches the old F10 + /// behavior — clear → overcast → rain → snow → storm). + /// + public Action? CycleWeather { get; set; } + + /// + /// Toggle the collision-wires debug renderer. Same effect as the + /// old F2 keybind, which we keep as a hotkey alias. + /// + public Action? ToggleCollisionWires { get; set; } + + // ── Combat event ring + toast ring ───────────────────────────────── + + /// + /// Snapshot view of the combat-event ring. Oldest-first; the panel + /// can iterate and render each line through TextColored + /// based on . + /// + public IReadOnlyCollection CombatEvents => _combatEvents; + + /// Snapshot view of the recent-toasts ring (oldest-first). + public IReadOnlyCollection RecentToasts => _toasts; + + /// + /// Append a toast message to the ring. Cap at + /// ; oldest entries drop. The panel's + /// "Recent toasts" section reads this; no on-screen flash for I.2. + /// + public void AddToast(string text, ToastKind kind = ToastKind.Info) + { + if (string.IsNullOrEmpty(text)) return; + _toasts.Enqueue(new ToastMessage(DateTime.UtcNow, kind, text)); + while (_toasts.Count > MaxRecentToasts) + _toasts.Dequeue(); + } + + private void Push(CombatEventKind kind, string text) + { + _combatEvents.Enqueue(new CombatEventLine(DateTime.UtcNow, kind, text)); + while (_combatEvents.Count > MaxCombatEvents) + _combatEvents.Dequeue(); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs new file mode 100644 index 0000000..33b0fde --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs @@ -0,0 +1,288 @@ +using System.Numerics; +using AcDream.Core.Combat; +using AcDream.UI.Abstractions.Panels.Debug; + +namespace AcDream.UI.Abstractions.Tests.Panels.Debug; + +/// +/// Tests for the Phase I.2 — read-through ViewModel +/// for the migrated debug panel. Verifies the combat-event subscription +/// (replacing the old DebugOverlay.BindCombat), the toast ring +/// cap, and that the diagnostic-flag bools round-trip without affecting +/// the rest of the VM. +/// +public sealed class DebugVMTests +{ + /// + /// Build a minimal with safe defaults for every + /// constructor source. Tests that don't care about a particular source + /// just leave it stubbed out. + /// + private static DebugVM NewVm(CombatState? combat = null) + { + combat ??= new CombatState(); + return new DebugVM( + getPlayerPosition: () => Vector3.Zero, + getPlayerHeadingDeg: () => 0f, + getPlayerCellId: () => 0u, + getPlayerOnGround: () => true, + getInPlayerMode: () => false, + getInFlyMode: () => false, + getVerticalVelocity: () => 0f, + getEntityCount: () => 0, + getAnimatedCount: () => 0, + getLandblocksVisible: () => 0, + getLandblocksTotal: () => 0, + getShadowObjectCount: () => 0, + getNearestObjDist: () => float.PositiveInfinity, + getNearestObjLabel: () => "-", + getColliding: () => false, + getDebugWireframes: () => false, + getStreamingRadius: () => 2, + getMouseSensitivity: () => 1f, + getChaseDistance: () => 0f, + getRmbOrbit: () => false, + getHourName: () => "Dawnsong", + getDayFraction: () => 0.25f, + getWeather: () => "Clear", + getActiveLights: () => 0, + getRegisteredLights: () => 0, + getParticleCount: () => 0, + getFps: () => 60f, + getFrameMs: () => 16.7f, + combat: combat); + } + + [Fact] + public void Constructor_ThrowsOnNullCombat() + { + Assert.Throws(() => new DebugVM( + getPlayerPosition: () => Vector3.Zero, + getPlayerHeadingDeg: () => 0f, + getPlayerCellId: () => 0u, + getPlayerOnGround: () => true, + getInPlayerMode: () => false, + getInFlyMode: () => false, + getVerticalVelocity: () => 0f, + getEntityCount: () => 0, + getAnimatedCount: () => 0, + getLandblocksVisible: () => 0, + getLandblocksTotal: () => 0, + getShadowObjectCount: () => 0, + getNearestObjDist: () => 0f, + getNearestObjLabel: () => "-", + getColliding: () => false, + getDebugWireframes: () => false, + getStreamingRadius: () => 0, + getMouseSensitivity: () => 1f, + getChaseDistance: () => 0f, + getRmbOrbit: () => false, + getHourName: () => "", + getDayFraction: () => 0f, + getWeather: () => "", + getActiveLights: () => 0, + getRegisteredLights: () => 0, + getParticleCount: () => 0, + getFps: () => 0f, + getFrameMs: () => 0f, + combat: null!)); + } + + [Fact] + public void ReadThrough_PullsLiveValuesPerAccess_NoCache() + { + // The VM does NOT cache; every property read goes through the + // backing Func. Mutating an external box between two reads + // must surface immediately. + int counter = 0; + var vm = new DebugVM( + getPlayerPosition: () => Vector3.Zero, + getPlayerHeadingDeg: () => 0f, + getPlayerCellId: () => 0u, + getPlayerOnGround: () => true, + getInPlayerMode: () => false, + getInFlyMode: () => false, + getVerticalVelocity: () => 0f, + getEntityCount: () => ++counter, + getAnimatedCount: () => 0, + getLandblocksVisible: () => 0, + getLandblocksTotal: () => 0, + getShadowObjectCount: () => 0, + getNearestObjDist: () => 0f, + getNearestObjLabel: () => "-", + getColliding: () => false, + getDebugWireframes: () => false, + getStreamingRadius: () => 0, + getMouseSensitivity: () => 1f, + getChaseDistance: () => 0f, + getRmbOrbit: () => false, + getHourName: () => "", + getDayFraction: () => 0f, + getWeather: () => "", + getActiveLights: () => 0, + getRegisteredLights: () => 0, + getParticleCount: () => 0, + getFps: () => 0f, + getFrameMs: () => 0f, + combat: new CombatState()); + + Assert.Equal(1, vm.EntityCount); + Assert.Equal(2, vm.EntityCount); + Assert.Equal(3, vm.EntityCount); + } + + [Fact] + public void DamageTaken_AppendsErrorEvent() + { + var combat = new CombatState(); + var vm = NewVm(combat); + + combat.OnVictimNotification( + attackerName: "Drudge", attackerGuid: 0x10u, + damageType: 0u, damage: 12u, hitQuadrant: 0u, + critical: 0u, attackType: 0u); + + var events = vm.CombatEvents.ToList(); + Assert.Single(events); + Assert.Equal(CombatEventKind.Error, events[0].Kind); + Assert.Contains("Drudge", events[0].Text); + Assert.Contains("12", events[0].Text); + } + + [Fact] + public void DamageDealt_AppendsInfoEvent() + { + var combat = new CombatState(); + var vm = NewVm(combat); + + combat.OnAttackerNotification( + defenderName: "Drudge", damageType: 0u, + damage: 8u, damagePercent: 0.1f); + + var events = vm.CombatEvents.ToList(); + Assert.Single(events); + Assert.Equal(CombatEventKind.Info, events[0].Kind); + Assert.Contains("Drudge", events[0].Text); + } + + [Fact] + public void EvadedIncoming_AppendsWarnEvent() + { + var combat = new CombatState(); + var vm = NewVm(combat); + + combat.OnEvasionDefenderNotification("Mosswart"); + + var events = vm.CombatEvents.ToList(); + Assert.Single(events); + Assert.Equal(CombatEventKind.Warn, events[0].Kind); + Assert.Contains("Mosswart", events[0].Text); + } + + [Fact] + public void CombatEventRing_CapsAtMax_DropsOldest() + { + var combat = new CombatState(); + var vm = NewVm(combat); + + // Pump well past the cap (25). The oldest entries must drop out. + for (int i = 0; i < 40; i++) + { + combat.OnAttackerNotification( + defenderName: $"Foe{i}", damageType: 0u, + damage: (uint)i, damagePercent: 0.1f); + } + + var events = vm.CombatEvents.ToList(); + Assert.Equal(DebugVM.MaxCombatEvents, events.Count); + // The newest entry must still be present. + Assert.Contains(events, e => e.Text.Contains("Foe39")); + // The oldest must have dropped. + Assert.DoesNotContain(events, e => e.Text.Contains("Foe0,") || e.Text == "Foe0"); + Assert.DoesNotContain(events, e => e.Text.Contains("Foe5")); + } + + [Fact] + public void Toast_RingCapsAtMaxRecent() + { + var vm = NewVm(); + + for (int i = 0; i < 30; i++) + vm.AddToast($"toast-{i}"); + + var toasts = vm.RecentToasts.ToList(); + Assert.Equal(DebugVM.MaxRecentToasts, toasts.Count); + Assert.Contains(toasts, t => t.Text == "toast-29"); + Assert.DoesNotContain(toasts, t => t.Text == "toast-0"); + } + + [Fact] + public void Toast_PreservesKind() + { + var vm = NewVm(); + vm.AddToast("warning!", ToastKind.Warn); + vm.AddToast("error!", ToastKind.Error); + + var toasts = vm.RecentToasts.ToList(); + Assert.Equal(2, toasts.Count); + Assert.Contains(toasts, t => t.Text == "warning!" && t.Kind == ToastKind.Warn); + Assert.Contains(toasts, t => t.Text == "error!" && t.Kind == ToastKind.Error); + } + + [Fact] + public void DiagnosticFlags_DefaultFalse_RoundTripIndependently() + { + var vm = NewVm(); + Assert.False(vm.DumpMotion); + Assert.False(vm.DumpVitals); + Assert.False(vm.DumpOpcodes); + Assert.False(vm.DumpSky); + + vm.DumpMotion = true; + Assert.True(vm.DumpMotion); + Assert.False(vm.DumpVitals); + Assert.False(vm.DumpOpcodes); + Assert.False(vm.DumpSky); + + vm.DumpSky = true; + vm.DumpMotion = false; + Assert.False(vm.DumpMotion); + Assert.True(vm.DumpSky); + } + + [Fact] + public void ToggleFlags_DoNotAffectCombatRing() + { + var combat = new CombatState(); + var vm = NewVm(combat); + + combat.OnAttackerNotification("X", 0u, 1u, 0.1f); + Assert.Single(vm.CombatEvents); + + vm.DumpMotion = true; + vm.DumpVitals = true; + Assert.Single(vm.CombatEvents); + } + + [Fact] + public void ActionHooks_InvokeSuppliedDelegates() + { + // The panel needs to invoke "cycle time", "cycle weather", "toggle + // collision wires" actions when the corresponding Button is + // clicked. The VM exposes these as Action; the panel calls them. + int timeHits = 0, weatherHits = 0, wireHits = 0; + var vm = NewVm(); + vm.CycleTimeOfDay = () => timeHits++; + vm.CycleWeather = () => weatherHits++; + vm.ToggleCollisionWires = () => wireHits++; + + vm.CycleTimeOfDay?.Invoke(); + vm.CycleTimeOfDay?.Invoke(); + vm.CycleWeather?.Invoke(); + vm.ToggleCollisionWires?.Invoke(); + + Assert.Equal(2, timeHits); + Assert.Equal(1, weatherHits); + Assert.Equal(1, wireHits); + } +}