using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; namespace AcDream.App.Rendering; /// /// Screen-space debug HUD. Composes panels on top of the 3D scene using a /// + . Panels can be /// toggled independently (info / stats / controls-help / compass). /// /// The overlay is stateless w.r.t. game state — callers populate a /// each frame and pass it to . /// public sealed class DebugOverlay { private readonly TextRenderer _text; private readonly BitmapFont _font; public bool ShowInfoPanel { get; set; } = true; public bool ShowStatsPanel { get; set; } = true; public bool ShowHelpPanel { get; set; } = false; public bool ShowCompass { get; set; } = true; public bool ShowChatPanel { get; set; } = true; public bool ShowEventPanel { get; set; } = true; /// /// Live chat log to render in the bottom-left chat panel. Hook this /// up to the session's ChatLog so server-sent ChannelBroadcast / Tell / /// HearSpeech / system messages appear on screen. /// public ChatLog? Chat { get; set; } /// Live combat state for damage floaters + target HP display. public CombatState? Combat { get; set; } // Tail of recent combat events, captured from CombatState callbacks so // they stay on-screen long enough to read even when you're moving fast. private readonly List<(string text, Vector4 color, float timeLeft)> _eventTail = new(); private const int MaxEventTail = 8; private const float EventHoldSec = 5f; /// /// Bind to CombatState events so incoming damage / evasion events /// surface as transient event-log lines. Call once after Combat is set. /// public void BindCombat(CombatState combat) { combat.DamageTaken += d => PushEvent( $"<< {d.AttackerName} hits you for {d.Damage}{(d.Critical ? " CRIT!" : "")}", Red); combat.DamageDealtAccepted += d => PushEvent( $">> you hit {d.DefenderName} for {d.Damage}", Yellow); combat.EvadedIncoming += a => PushEvent( $"<< {a}'s attack misses you", Green); combat.MissedOutgoing += a => PushEvent( $">> your attack misses {a}", Grey); } private void PushEvent(string text, Vector4 color) { _eventTail.Add((text, color, EventHoldSec)); while (_eventTail.Count > MaxEventTail) _eventTail.RemoveAt(0); } // Toast state for transient notifications (e.g. "wireframes off"). private string? _toastText; private Vector4 _toastColor = White; private float _toastTimeLeft; private static readonly Vector4 White = new(1f, 1f, 1f, 1f); private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f); private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f); private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f); private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f); private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f); private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f); private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f); /// Per-frame state snapshot from the caller. See . public readonly record struct Snapshot( float Fps, float FrameTimeMs, Vector3 PlayerPos, float HeadingDeg, uint CellId, bool OnGround, bool InPlayerMode, bool InFlyMode, float VerticalVelocity, int EntityCount, int AnimatedCount, int LandblocksVisible, int LandblocksTotal, int ShadowObjectCount, float NearestObjDist, string NearestObjLabel, bool Colliding, bool DebugWireframes, int StreamingRadius, float MouseSensitivity, float ChaseDistance, bool RmbOrbit); public DebugOverlay(TextRenderer text, BitmapFont font) { _text = text; _font = font; } /// Show a short message in the center-top for seconds. public void Toast(string message, float durationSec = 1.5f, Vector4? color = null) { _toastText = message; _toastColor = color ?? Yellow; _toastTimeLeft = durationSec; } /// Advance toast timer. Call once per frame with dt in seconds. public void Update(float dt) { if (_toastTimeLeft > 0f) { _toastTimeLeft -= dt; if (_toastTimeLeft <= 0f) _toastText = null; } // Age event-tail entries; drop the ones that expired this frame. for (int i = _eventTail.Count - 1; i >= 0; i--) { var e = _eventTail[i]; e.timeLeft -= dt; if (e.timeLeft <= 0f) _eventTail.RemoveAt(i); else _eventTail[i] = e; } } public void Draw(Snapshot s, Vector2 screenSize) { _text.Begin(screenSize); if (ShowInfoPanel) DrawInfoPanel(s); if (ShowStatsPanel) DrawStatsPanel(s, screenSize); if (ShowCompass) DrawCompass(s, screenSize); if (ShowHelpPanel) DrawHelpPanel(screenSize); if (ShowChatPanel) DrawChatPanel(screenSize); if (ShowEventPanel) DrawEventPanel(screenSize); DrawHintBar(screenSize); DrawToast(screenSize); _text.Flush(_font); } // ────────────────────────────────────────────────────────────────────── // Info panel — top-left: mode, position, heading, ground, nearest-obj. // ────────────────────────────────────────────────────────────────────── private void DrawInfoPanel(Snapshot s) { var lines = new List<(string text, Vector4 color)>(); string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT "); var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey); lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}", modeColor)); lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White)); lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White)); var gColor = s.OnGround ? Green : Yellow; string vzStr = s.VerticalVelocity >= 0 ? $"+{s.VerticalVelocity,4:F2}" : $"{s.VerticalVelocity,5:F2}"; lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor)); var nColor = s.Colliding ? Red : White; string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m"; lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor)); var cColor = s.Colliding ? Red : Green; lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor)); if (s.InPlayerMode) { string orbitTag = s.RmbOrbit ? " [RMB orbit]" : ""; lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}", s.RmbOrbit ? Cyan : White)); } lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey)); DrawPanel(10f, 10f, lines); } // ────────────────────────────────────────────────────────────────────── // Stats panel — top-right: fps, frame time, landblock/entity counters. // ────────────────────────────────────────────────────────────────────── private void DrawStatsPanel(Snapshot s, Vector2 screenSize) { var lines = new List<(string text, Vector4 color)> { ($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green), ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White), ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White), ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White), }; 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); } // ────────────────────────────────────────────────────────────────────── // 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 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; } }