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:
parent
3d26c8efde
commit
56037a4471
5 changed files with 1081 additions and 639 deletions
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -30,18 +30,29 @@ public sealed class GameWindow : IDisposable
|
||||||
private bool _debugCollisionVisible = true;
|
private bool _debugCollisionVisible = true;
|
||||||
private int _debugDrawLogOnce = 0;
|
private int _debugDrawLogOnce = 0;
|
||||||
|
|
||||||
// On-screen debug HUD — info panel, stats panel, compass, keybind help.
|
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
|
||||||
// F1/F2/F4/F5/F6 toggle the individual panels (see the key handler).
|
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
|
||||||
// Null if no system font is available at startup; in that case the HUD
|
// TextRenderer + BitmapFont fields stay alive because they're shared
|
||||||
// is silently disabled and the rest of the client keeps working.
|
// 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 TextRenderer? _textRenderer;
|
||||||
private BitmapFont? _debugFont;
|
private BitmapFont? _debugFont;
|
||||||
private DebugOverlay? _debugOverlay;
|
|
||||||
// Last-computed perf values so the HUD always has something to show even
|
// Last-computed perf values so the HUD always has something to show even
|
||||||
// though the title-bar FPS is only updated every 0.5s.
|
// though the title-bar FPS is only updated every 0.5s.
|
||||||
private double _lastFps = 60.0;
|
private double _lastFps = 60.0;
|
||||||
private double _lastFrameMs = 16.7;
|
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.
|
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||||
private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
|
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.ImGuiBootstrapper? _imguiBootstrap;
|
||||||
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
|
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
|
||||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
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 =
|
private static readonly bool DevToolsEnabled =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
|
||||||
|
|
||||||
|
|
@ -537,85 +552,34 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
else if (key == Key.F1)
|
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;
|
panel.IsVisible = !panel.IsVisible;
|
||||||
_debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}");
|
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (key == Key.F2)
|
else if (key == Key.F2)
|
||||||
{
|
{
|
||||||
_debugCollisionVisible = !_debugCollisionVisible;
|
// Real gameplay toggle — keeps the F2 keybind. Same
|
||||||
_debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
|
// action is wired into the DebugPanel's
|
||||||
}
|
// "Toggle collision wires" button via DebugVM.
|
||||||
else if (key == Key.F4)
|
ToggleCollisionWires();
|
||||||
{
|
|
||||||
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")}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (key == Key.F7)
|
else if (key == Key.F7)
|
||||||
{
|
{
|
||||||
// Phase G.1: cycle debug time-of-day overrides. Useful for
|
// Phase I.2: keep F7 as a hotkey alias for the
|
||||||
// visually verifying the sun arc + keyframe transitions
|
// DebugPanel's "Cycle time of day" button.
|
||||||
// without waiting 30+ real-time hours. Cycle order:
|
CycleTimeOfDay();
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (key == Key.F10)
|
else if (key == Key.F10)
|
||||||
{
|
{
|
||||||
// Phase G.1: cycle weather kinds manually. Useful for
|
// Phase I.2: keep F10 as a hotkey alias for the
|
||||||
// testing the rain/snow particle systems + storm/light
|
// DebugPanel's "Cycle weather" button.
|
||||||
// fog without waiting for the daily RNG to hit.
|
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]);
|
|
||||||
_debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}");
|
|
||||||
}
|
}
|
||||||
else if (key == Key.F8 || key == Key.F9)
|
else if (key == Key.F8 || key == Key.F9)
|
||||||
{
|
{
|
||||||
|
|
@ -638,7 +602,7 @@ public sealed class GameWindow : IDisposable
|
||||||
else if (modeLabel == "Fly") _sensFly = next;
|
else if (modeLabel == "Fly") _sensFly = next;
|
||||||
else _sensOrbit = next;
|
else _sensOrbit = next;
|
||||||
|
|
||||||
_debugOverlay?.Toast($"{modeLabel} sens {next:F3}x");
|
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
|
||||||
}
|
}
|
||||||
else if (key == Key.Escape)
|
else if (key == Key.Escape)
|
||||||
{
|
{
|
||||||
|
|
@ -857,25 +821,23 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
_debugLines = new DebugLineRenderer(_gl, shadersDir);
|
_debugLines = new DebugLineRenderer(_gl, shadersDir);
|
||||||
|
|
||||||
// Debug HUD: load a system monospace font and set up the text overlay.
|
// Phase I.2: load a system monospace font + TextRenderer for the
|
||||||
// Skips silently if no font is available (the rest of the client still works).
|
// 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();
|
var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
|
||||||
if (fontBytes is not null)
|
if (fontBytes is not null)
|
||||||
{
|
{
|
||||||
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
|
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
|
||||||
_textRenderer = new TextRenderer(_gl, shadersDir);
|
_textRenderer = new TextRenderer(_gl, shadersDir);
|
||||||
_debugOverlay = new DebugOverlay(_textRenderer, _debugFont);
|
Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " +
|
||||||
// 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, " +
|
|
||||||
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
|
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
|
||||||
$"lineHeight={_debugFont.LineHeight:F1}px");
|
$"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)");
|
||||||
}
|
}
|
||||||
else
|
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 };
|
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
|
||||||
|
|
@ -959,7 +921,50 @@ public sealed class GameWindow : IDisposable
|
||||||
_panelHost.Register(
|
_panelHost.Register(
|
||||||
new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm));
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -968,6 +973,8 @@ public sealed class GameWindow : IDisposable
|
||||||
_imguiBootstrap = null;
|
_imguiBootstrap = null;
|
||||||
_panelHost = null;
|
_panelHost = null;
|
||||||
_vitalsVm = null;
|
_vitalsVm = null;
|
||||||
|
_debugVm = null;
|
||||||
|
_debugPanel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4119,48 +4126,32 @@ public sealed class GameWindow : IDisposable
|
||||||
visibleLandblocks++;
|
visibleLandblocks++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Debug HUD overlay ────────────────────────────────────────────
|
// Phase I.2: refresh per-frame fields that DebugVM closures
|
||||||
// Build a per-frame snapshot of state we want to show and hand it
|
// can't compute lazily (frustum-derived counters + nearest-
|
||||||
// to the overlay. Drawn after all 3D passes so it sits on top.
|
// object scan). Every other DebugVM field reads through to
|
||||||
if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null)
|
// 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;
|
_lastVisibleLandblocks = visibleLandblocks;
|
||||||
float headingDeg;
|
_lastTotalLandblocks = totalLandblocks;
|
||||||
uint cellId;
|
|
||||||
bool onGround;
|
// Compute fly/orbit-mode camera position for the nearest-
|
||||||
float vVel;
|
// object scan when not in player mode.
|
||||||
if (_playerMode && _playerController is not null)
|
System.Numerics.Vector3 nearOrigin;
|
||||||
{
|
if (_playerMode && _playerController is not null)
|
||||||
playerPos = _playerController.Position;
|
nearOrigin = _playerController.Position;
|
||||||
// Yaw in math convention: 0 = +X east, PI/2 = +Y north.
|
else
|
||||||
// Convert to degrees in [0, 360).
|
nearOrigin = camPos;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nearest shadow object — surface-to-surface distance in XY
|
|
||||||
// (subtract player radius + obj radius). Negative == penetrating.
|
|
||||||
const float playerRadius = 0.48f;
|
const float playerRadius = 0.48f;
|
||||||
float bestDist = float.PositiveInfinity;
|
float bestDist = float.PositiveInfinity;
|
||||||
string bestLabel = "-";
|
string bestLabel = "-";
|
||||||
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
|
||||||
{
|
{
|
||||||
float dx = obj.Position.X - playerPos.X;
|
float dx = obj.Position.X - nearOrigin.X;
|
||||||
float dy = obj.Position.Y - playerPos.Y;
|
float dy = obj.Position.Y - nearOrigin.Y;
|
||||||
float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
|
float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
|
||||||
if (d < bestDist)
|
if (d < bestDist)
|
||||||
{
|
{
|
||||||
|
|
@ -4168,53 +4159,9 @@ public sealed class GameWindow : IDisposable
|
||||||
bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
|
bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool colliding = bestDist < 0.05f;
|
_lastColliding = bestDist < 0.05f;
|
||||||
if (bestDist < 0f) bestDist = 0f;
|
_lastNearestObjDist = bestDist < 0f ? 0f : bestDist;
|
||||||
|
_lastNearestObjLabel = bestLabel;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4939,6 +4886,146 @@ public sealed class GameWindow : IDisposable
|
||||||
EndSize = 0.06f,
|
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()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||||
|
|
|
||||||
250
src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
Normal file
250
src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
Normal file
289
src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
Normal 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<T></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();
|
||||||
|
}
|
||||||
|
}
|
||||||
288
tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
Normal file
288
tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue