feat(overlay): surface Chat + Combat state in DebugOverlay + ship OpenAL-Soft native

Two visible wins that prove today's wire-layer work is actually doing
something:

1. Chat panel (bottom-left): live tail of the last 10 ChatLog entries.
   Color-coded by kind — Tell cyan, Channel green, System yellow, Popup
   red, local/ranged white. Bound to WorldSession via the existing
   GameEventWiring path (H.1) so server ChannelBroadcast / Tell /
   TransientString / Popup + HearSpeech all render live. Includes a
   synthetic \"connecting / connected\" system message so the panel
   isn't blank before anyone talks.

2. Event panel (bottom-right): last 8 combat events from CombatState
   (E.4). Damage taken (red), damage dealt (yellow), evaded-incoming
   (green), missed-outgoing (grey). Each entry fades out over 5s.
   DebugOverlay.BindCombat wires the listeners.

3. Silk.NET.OpenAL.Soft.Native 1.23.1 added. Before this, OpenAL
   managed bindings loaded but the native runtime was absent so
   IsAvailable returned false and audio was silently disabled. Now
   soft_oal.dll ships to runtimes/win-x64/native/ and the engine
   reports \"OpenAL engine ready (16 voices, 3D positional)\" on
   launch. Footsteps + other motion-hook sounds now audible.

GameWindow: after constructing DebugOverlay, assign .Chat + .Combat
and call BindCombat to hook the event stream.

Build green, 628 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-18 23:11:50 +02:00
parent 188762cd43
commit 04c98be154
4 changed files with 136 additions and 0 deletions

View file

@ -16,6 +16,7 @@
<PackageReference Include="Silk.NET.OpenAL" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL.Extensions.Creative" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL.Extensions.EXT" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL.Soft.Native" Version="1.23.1" />
<PackageReference Include="Serilog" Version="4.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
namespace AcDream.App.Rendering;
@ -21,6 +23,46 @@ public sealed class DebugOverlay
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;
@ -84,6 +126,15 @@ public sealed class DebugOverlay
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)
@ -94,6 +145,8 @@ public sealed class DebugOverlay
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);
@ -253,6 +306,82 @@ public sealed class DebugOverlay
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.
// ──────────────────────────────────────────────────────────────────────

View file

@ -567,6 +567,10 @@ public sealed class GameWindow : IDisposable
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
_textRenderer = new TextRenderer(_gl, shadersDir);
_debugOverlay = new DebugOverlay(_textRenderer, _debugFont);
// Phase F.1/H.1/E.4 visibility: show chat + combat events on screen.
_debugOverlay.Chat = Chat;
_debugOverlay.Combat = Combat;
_debugOverlay.BindCombat(Combat);
Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " +
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
$"lineHeight={_debugFont.LineHeight:F1}px");
@ -740,7 +744,9 @@ public sealed class GameWindow : IDisposable
senderGuid: speech.SenderGuid,
isRanged: speech.IsRanged);
Chat.OnSystemMessage($"connecting to {host}:{portStr} as {user}", chatType: 1);
_liveSession.Connect(user, pass);
Chat.OnSystemMessage("connected — character list received", chatType: 1);
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
{