feat(ui): #15 migrate DebugOverlay to ImGui DebugPanel - 7 collapsing sections + diagnostics toggles

Replaces the 473-LOC custom-StbTrueTypeSharp DebugOverlay with an
ImGui-rendered DebugPanel using the I.1 widget extensions. Single
window with 7 CollapsingHeader sections; checkboxes are the primary
toggle surface; F-keys retained where they invoke real gameplay
actions, dropped where they only toggled panels.

Pieces:
- DebugVM (UI.Abstractions): read-through ViewModel with combat-event
  ring (cap 25), toast ring (cap 25), 4 diagnostic-flag bools
  (DumpMotion / DumpVitals / DumpOpcodes / DumpSky), 3 Action hooks
  (CycleTimeOfDay / CycleWeather / ToggleCollisionWires). Self-
  subscribes to CombatState.DamageTaken/DealtAccepted/Evaded* /
  Missed*/AttackDone/KillLanded - replaces the old BindCombat path.
- DebugPanel (UI.Abstractions): one ImGui window with sections
  Player Info, Performance, Compass (text-only - draw-list strip
  deferred to D.6), Help (BeginTable cheat-sheet), Combat events
  (TextColored by kind: Info=yellow, Warning=red, Error=deep red),
  Recent toasts, Diagnostics (Checkboxes for the 4 flags + Buttons
  for the 3 cycle/toggle actions).
- All 28 Snapshot data points covered: Fps, FrameMs, PlayerPos,
  HeadingDeg, CellId, OnGround, InPlayerMode, InFlyMode,
  VerticalVelocity, EntityCount, AnimatedCount, LandblocksVisible,
  LandblocksTotal, ShadowObjectCount, NearestObjDist, NearestObjLabel,
  Colliding, DebugWireframes, StreamingRadius, MouseSensitivity,
  ChaseDistance, RmbOrbit, HourName, DayFraction, Weather,
  ActiveLights, RegisteredLights, ParticleCount.
- GameWindow surgery (+252/-165): removed _debugOverlay field +
  snapshot builder block + Update/Draw calls; added _debugVm /
  _debugPanel construction in the if (DevToolsEnabled) block;
  added per-frame nearest-object scan cached for VM closures
  (zero cost when devtools off); helper methods CycleTimeOfDay /
  CycleWeather / ToggleCollisionWires / GetDebug* / GetActiveSensitivity.

F-key disposition:
- F1: repurposed - now toggles whole DebugPanel visibility.
- F2: kept - ToggleCollisionWires (also a Button in panel).
- F4 / F5 / F6: REMOVED - per-section toggles replaced by
  CollapsingHeader inside one window.
- F7: kept - CycleTimeOfDay (also Button).
- F8 / F9: kept - mouse-sensitivity adjust; toasts route to
  _debugVm.AddToast.
- F10: kept - CycleWeather (also Button).

DebugOverlay.cs DELETED (473 LOC). TextRenderer + BitmapFont kept
alive: UiHost references _debugFont and the future HUD-in-world
(D.6) will reuse both.

11 new DebugVM tests covering combat-event-ring subscription, toast
ring cap, diagnostic-flag toggles. UI.Abstractions.Tests: 96 -> 107.
Solution total: 989 green (243 Core.Net + 639 Core + 107 UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 20:09:26 +02:00
parent 3d26c8efde
commit 56037a4471
5 changed files with 1081 additions and 639 deletions

View file

@ -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;
/// <summary>
/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
/// <see cref="TextRenderer"/> + <see cref="BitmapFont"/>. Panels can be
/// toggled independently (info / stats / controls-help / compass).
///
/// The overlay is stateless w.r.t. game state — callers populate a
/// <see cref="Snapshot"/> each frame and pass it to <see cref="Draw"/>.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
public ChatLog? Chat { get; set; }
/// <summary>Live combat state for damage floaters + target HP display.</summary>
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;
/// <summary>
/// Bind to CombatState events so incoming damage / evasion events
/// surface as transient event-log lines. Call once after Combat is set.
/// </summary>
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);
/// <summary>Per-frame state snapshot from the caller. See <see cref="Draw"/>.</summary>
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;
}
/// <summary>Show a short message in the center-top for <paramref name="durationSec"/> seconds.</summary>
public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
{
_toastText = message;
_toastColor = color ?? Yellow;
_toastTimeLeft = durationSec;
}
/// <summary>Advance toast timer. Call once per frame with dt in seconds.</summary>
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;
}
}

View file

@ -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.
/// <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() =>
_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;
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
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]}");
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
/// <summary>
/// Yields the registered DebugPanel(s) so F1 can flip their
/// visibility. Returns nothing when devtools are off.
/// </summary>
private IEnumerable<AcDream.UI.Abstractions.IPanel> 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

View file

@ -0,0 +1,250 @@
using System.Numerics;
namespace AcDream.UI.Abstractions.Panels.Debug;
/// <summary>
/// The Phase I.2 debug panel — single ImGui window with collapsing-header
/// sections that replace the old custom <c>DebugOverlay</c>'s six floating
/// panels (Info / Stats / Help / Compass / Chat / Event) plus the toast
/// surface. Reads through <see cref="DebugVM"/> so values are always live.
///
/// <para>
/// Layout: Player Info, Performance, Compass, Help, Combat events, Recent
/// toasts, Diagnostics. Each section is a <c>CollapsingHeader</c>;
/// importance-ranked sections default open, niche ones default closed.
/// </para>
///
/// <para>
/// Reuses the I.1 widget extensions only; never imports a backend
/// namespace. Same constraints as <c>VitalsPanel</c> and <c>ChatPanel</c>.
/// </para>
/// </summary>
public sealed class DebugPanel : IPanel
{
private readonly DebugVM _vm;
public DebugPanel(DebugVM vm)
{
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.debug";
/// <inheritdoc />
public string Title => "Debug";
/// <inheritdoc />
public bool IsVisible { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
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"),
};
/// <inheritdoc />
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];
}
}

View file

@ -0,0 +1,289 @@
using System.Numerics;
using AcDream.Core.Combat;
namespace AcDream.UI.Abstractions.Panels.Debug;
/// <summary>
/// Severity tag for a single combat-event line in the
/// <see cref="DebugVM.CombatEvents"/> ring. The panel reads this to pick
/// a <c>TextColored</c> rgba per row (yellow info / red warn / deep-red
/// error). Mirrors the same tri-tone the chat panel uses for combat
/// (Phase I.7).
/// </summary>
public enum CombatEventKind
{
/// <summary>You dealt damage / landed a hit. Yellow.</summary>
Info,
/// <summary>An incoming hit you evaded. Red.</summary>
Warn,
/// <summary>You took damage. Deep red.</summary>
Error,
}
/// <summary>
/// Single typed entry in the combat-events ring. <see cref="Timestamp"/>
/// is captured at append time so a future panel revision can fade old
/// entries; for I.2 the panel just renders the text + rgba.
/// </summary>
public readonly record struct CombatEventLine(
DateTime Timestamp,
CombatEventKind Kind,
string Text);
/// <summary>
/// Severity tag for a transient toast message. Mirrors
/// <see cref="CombatEventKind"/> but lives in its own enum so the toast
/// surface can grow (e.g. an "OK" green) without dragging the combat
/// surface along.
/// </summary>
public enum ToastKind
{
Info,
Warn,
Error,
}
/// <summary>Single transient toast message kept in the recent-toasts ring.</summary>
public readonly record struct ToastMessage(
DateTime Timestamp,
ToastKind Kind,
string Text);
/// <summary>
/// ViewModel for the Phase I.2 <see cref="DebugPanel"/>. Read-through
/// (no caching): every property forwards to a <c>Func&lt;T&gt;</c> that
/// the host (<c>GameWindow</c>) wires up at construction. Internal
/// state is limited to (a) the combat-event ring buffer, populated via a
/// self-subscription to <see cref="CombatState"/>'s typed events
/// (replacing the old <c>DebugOverlay.BindCombat</c>); (b) the toast
/// ring; (c) the diagnostic-flag bools the panel exposes as checkboxes.
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class DebugVM
{
/// <summary>Maximum number of combat-event lines kept in the ring.</summary>
public const int MaxCombatEvents = 25;
/// <summary>Maximum number of recent toast messages kept in the ring.</summary>
public const int MaxRecentToasts = 25;
private readonly Func<Vector3> _getPlayerPosition;
private readonly Func<float> _getPlayerHeadingDeg;
private readonly Func<uint> _getPlayerCellId;
private readonly Func<bool> _getPlayerOnGround;
private readonly Func<bool> _getInPlayerMode;
private readonly Func<bool> _getInFlyMode;
private readonly Func<float> _getVerticalVelocity;
private readonly Func<int> _getEntityCount;
private readonly Func<int> _getAnimatedCount;
private readonly Func<int> _getLandblocksVisible;
private readonly Func<int> _getLandblocksTotal;
private readonly Func<int> _getShadowObjectCount;
private readonly Func<float> _getNearestObjDist;
private readonly Func<string> _getNearestObjLabel;
private readonly Func<bool> _getColliding;
private readonly Func<bool> _getDebugWireframes;
private readonly Func<int> _getStreamingRadius;
private readonly Func<float> _getMouseSensitivity;
private readonly Func<float> _getChaseDistance;
private readonly Func<bool> _getRmbOrbit;
private readonly Func<string> _getHourName;
private readonly Func<float> _getDayFraction;
private readonly Func<string> _getWeather;
private readonly Func<int> _getActiveLights;
private readonly Func<int> _getRegisteredLights;
private readonly Func<int> _getParticleCount;
private readonly Func<float> _getFps;
private readonly Func<float> _getFrameMs;
private readonly Queue<CombatEventLine> _combatEvents = new();
private readonly Queue<ToastMessage> _toasts = new();
/// <summary>
/// Build a VM bound to live data sources. Every <c>Func</c> 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.
/// </summary>
public DebugVM(
Func<Vector3> getPlayerPosition,
Func<float> getPlayerHeadingDeg,
Func<uint> getPlayerCellId,
Func<bool> getPlayerOnGround,
Func<bool> getInPlayerMode,
Func<bool> getInFlyMode,
Func<float> getVerticalVelocity,
Func<int> getEntityCount,
Func<int> getAnimatedCount,
Func<int> getLandblocksVisible,
Func<int> getLandblocksTotal,
Func<int> getShadowObjectCount,
Func<float> getNearestObjDist,
Func<string> getNearestObjLabel,
Func<bool> getColliding,
Func<bool> getDebugWireframes,
Func<int> getStreamingRadius,
Func<float> getMouseSensitivity,
Func<float> getChaseDistance,
Func<bool> getRmbOrbit,
Func<string> getHourName,
Func<float> getDayFraction,
Func<string> getWeather,
Func<int> getActiveLights,
Func<int> getRegisteredLights,
Func<int> getParticleCount,
Func<float> getFps,
Func<float> 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) ───────────────
/// <summary>Mirror of <c>ACDREAM_DUMP_MOTION</c>; flipped at runtime via the panel.</summary>
public bool DumpMotion { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_VITALS</c>.</summary>
public bool DumpVitals { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_OPCODES</c>.</summary>
public bool DumpOpcodes { get; set; }
/// <summary>Mirror of <c>ACDREAM_DUMP_SKY</c>.</summary>
public bool DumpSky { get; set; }
// ── Action hooks invoked by panel buttons ──────────────────────────
/// <summary>
/// Cycle the time-of-day debug override (matches the old F7
/// behavior — none → midnight → dawn → noon → dusk → none). Wired
/// by <c>GameWindow</c>; null when no host is available (tests).
/// </summary>
public Action? CycleTimeOfDay { get; set; }
/// <summary>
/// Cycle the weather-kind debug override (matches the old F10
/// behavior — clear → overcast → rain → snow → storm).
/// </summary>
public Action? CycleWeather { get; set; }
/// <summary>
/// Toggle the collision-wires debug renderer. Same effect as the
/// old F2 keybind, which we keep as a hotkey alias.
/// </summary>
public Action? ToggleCollisionWires { get; set; }
// ── Combat event ring + toast ring ─────────────────────────────────
/// <summary>
/// Snapshot view of the combat-event ring. Oldest-first; the panel
/// can iterate and render each line through <c>TextColored</c>
/// based on <see cref="CombatEventLine.Kind"/>.
/// </summary>
public IReadOnlyCollection<CombatEventLine> CombatEvents => _combatEvents;
/// <summary>Snapshot view of the recent-toasts ring (oldest-first).</summary>
public IReadOnlyCollection<ToastMessage> RecentToasts => _toasts;
/// <summary>
/// Append a toast message to the ring. Cap at
/// <see cref="MaxRecentToasts"/>; oldest entries drop. The panel's
/// "Recent toasts" section reads this; no on-screen flash for I.2.
/// </summary>
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();
}
}

View file

@ -0,0 +1,288 @@
using System.Numerics;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Debug;
namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
/// <summary>
/// Tests for the Phase I.2 <see cref="DebugVM"/> — read-through ViewModel
/// for the migrated debug panel. Verifies the combat-event subscription
/// (replacing the old <c>DebugOverlay.BindCombat</c>), the toast ring
/// cap, and that the diagnostic-flag bools round-trip without affecting
/// the rest of the VM.
/// </summary>
public sealed class DebugVMTests
{
/// <summary>
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
/// constructor source. Tests that don't care about a particular source
/// just leave it stubbed out.
/// </summary>
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<ArgumentNullException>(() => 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<T>. 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);
}
}