feat(ui): debug overlay + refined input controls

Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
This commit is contained in:
Erik 2026-04-17 18:45:38 +02:00
parent 6b4e7569a3
commit ff325abd7b
20 changed files with 2734 additions and 268 deletions

View file

@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Numerics;
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;
// 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);
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;
}
}
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);
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),
};
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"),
("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);
}
// ──────────────────────────────────────────────────────────────────────
// Hint bar — bottom-left: always-visible "F1 for help" reminder.
// ──────────────────────────────────────────────────────────────────────
private void DrawHintBar(Vector2 screenSize)
{
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold 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;
}
}