WorldSession now surfaces the server's PortalYearTicks via a new
ServerTimeUpdated event, fired from two sources per r12 §12:
1. Initial ConnectRequest handshake (ConnectRequestServerTime field
of the optional block — seeds the clock on login).
2. Every subsequent packet carrying the TimeSync header flag
(0x01000000) — keeps the client clock within one TimeSync period
of authoritative server time.
GameWindow subscribes the event into WorldTimeService.SyncFromServer,
so the day/night cycle + keyframe interpolation runs from real server
time in live mode. Offline mode (ACDREAM_LIVE=0) still uses the
seeded-to-noon fallback from OnLoad.
DebugOverlay now exposes sky + weather + lighting state:
time 0.50 Midsong (day fraction + hour name)
wx Clear parts 0 (weather kind + live particle count)
lit 1/4 (active / registered lights)
F7 cycles a debug time override through
(none → midnight → dawn → noon → dusk → none)
F10 cycles weather through
(Clear → Overcast → Rain → Snow → Storm).
These keybinds satisfy the visual-verification tier so a user can
flip through every state from the running client without touching
the code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
472 lines
20 KiB
C#
472 lines
20 KiB
C#
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;
|
|
}
|
|
}
|