MosswartMassacre/MosswartMassacre/Views/VitalSharingOverlayView.cs
Erik d220970130 feat(vitalsharing): UB-style DxHud overlay + self in peer list
- Rewrite VitalSharingOverlayView as a DxHud drawn directly onto the
  game surface with the same row layout UB NetworkUI uses: full-width
  HP bar on top, Stamina + Mana bars side-by-side on the bottom, plus
  per-row border and name/percentage text. Colours use the sidebar
  palette (#ff4444 / #ffaa00 / #4488ff).
- Always include the local character as the first row so the overlay
  shows something even before any peer traffic arrives. Previously
  the window said "(no peers)" until another subscribed client
  streamed vitals in.
- VitalSharingTracker.GetSelfSnapshot() reads current vitals via
  CoreManager.Actions.Vital.
- Drop the old HudList-based XML view (no longer used).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:40:34 +02:00

284 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Drawing;
using Decal.Adapter;
using VirindiViewService;
namespace MosswartMassacre.Views
{
/// <summary>
/// In-game vital sharing overlay — a DxHud drawn directly onto the game
/// surface so the visual matches UtilityBelt's NetworkUI (DrawCharNameBackground).
/// One row per peer (including the local character), showing name, HP bar
/// across the full row, then Stamina (left half) and Mana (right half) at
/// the bottom half of the row. Colours match the MosswartOverlord player
/// sidebar palette (red / orange / blue).
///
/// Toggle with /mm vitalsharing overlay, via the Settings tab button, or
/// VitalSharingOverlayView.ToggleWindow().
/// </summary>
internal static class VitalSharingOverlayView
{
// Layout constants — identical to UB NetworkUI so rows line up the same.
private const int HUD_X_OFFSET = 50;
private const int HUD_Y_OFFSET = 200;
private const int ROW_SIZE = 20;
private const int HUD_WIDTH = 260;
private const int CHAR_NAME_WIDTH = 260;
private const int HEALTH_BAR_HEIGHT = 11;
// Colors matched to MosswartOverlord player sidebar (#ff4444 / #ffaa00 / #4488ff)
private const int HUD_OPACITY = 200;
private static readonly Color COLOR_HP_FILL = Color.FromArgb(HUD_OPACITY, 255, 68, 68);
private static readonly Color COLOR_HP_BG = Color.FromArgb(HUD_OPACITY, 60, 0, 0);
private static readonly Color COLOR_STA_FILL = Color.FromArgb(HUD_OPACITY, 255, 170, 0);
private static readonly Color COLOR_STA_BG = Color.FromArgb(HUD_OPACITY, 70, 30, 0);
private static readonly Color COLOR_MANA_FILL = Color.FromArgb(HUD_OPACITY, 68, 136, 255);
private static readonly Color COLOR_MANA_BG = Color.FromArgb(HUD_OPACITY, 0, 20, 55);
private static readonly Color COLOR_BORDER = Color.Black;
private static readonly Color COLOR_TEXT = Color.White;
private static readonly Color COLOR_TEXT_DARK = Color.FromArgb(255, 0, 0, 0);
private static DxHud _hud;
private static System.Windows.Forms.Timer _redrawTimer;
private static bool _visible;
private static int _lastRowCount;
public static bool IsVisible => _visible;
public static void ToggleWindow()
{
try
{
if (_visible) Hide();
else Show();
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay toggle error: {ex.Message}");
}
}
public static void Show()
{
try
{
if (_visible && _hud != null)
{
_hud.Enabled = true;
return;
}
var rows = Math.Max(1, CountRows());
var size = new Size(HUD_WIDTH, rows * ROW_SIZE + 5);
_hud = new DxHud(new Point(HUD_X_OFFSET, HUD_Y_OFFSET), size, 0)
{
Enabled = true,
Alpha = 255,
};
_visible = true;
_lastRowCount = rows;
if (_redrawTimer == null)
{
_redrawTimer = new System.Windows.Forms.Timer();
_redrawTimer.Interval = 250; // 4 fps is plenty for vital bars
_redrawTimer.Tick += (s, e) => Render();
}
_redrawTimer.Start();
Render();
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay show error: {ex.Message}");
}
}
public static void Hide()
{
try
{
_redrawTimer?.Stop();
if (_hud != null)
{
_hud.Enabled = false;
_hud.Dispose();
_hud = null;
}
_visible = false;
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay hide error: {ex.Message}");
}
}
private static int CountRows()
{
int n = VitalSharingTracker.GetPeerSnapshots()?.Length ?? 0;
// self is always drawn as the first row
return n + 1;
}
private static List<VitalSharingTracker.PeerSnapshot> BuildRows()
{
var rows = new List<VitalSharingTracker.PeerSnapshot>();
var self = VitalSharingTracker.GetSelfSnapshot();
if (self != null) rows.Add(self);
var peers = VitalSharingTracker.GetPeerSnapshots();
if (peers != null)
{
Array.Sort(peers, (a, b) =>
string.Compare(a.CharacterName, b.CharacterName, StringComparison.OrdinalIgnoreCase));
foreach (var p in peers)
{
if (self != null && string.Equals(p.CharacterName, self.CharacterName, StringComparison.OrdinalIgnoreCase))
continue;
rows.Add(p);
}
}
return rows;
}
private static void Render()
{
try
{
if (_hud == null || !_visible) return;
var rows = BuildRows();
if (rows.Count == 0) return;
// Resize the hud if the row count changed
if (rows.Count != _lastRowCount)
{
_lastRowCount = rows.Count;
// DxHud has no resize; re-create if size changed
var loc = _hud.Location;
_hud.Dispose();
_hud = new DxHud(loc, new Size(HUD_WIDTH, rows.Count * ROW_SIZE + 5), 0)
{
Enabled = true,
Alpha = 255,
};
}
var tex = _hud.Texture;
tex.BeginRender();
try
{
// Clear background
tex.Fill(new Rectangle(0, 0, HUD_WIDTH, rows.Count * ROW_SIZE + 5), Color.Transparent);
int offset = 0;
foreach (var p in rows)
{
DrawRowBackground(tex, offset, p);
offset += ROW_SIZE;
}
// Text passes
tex.BeginText("Arial", 7, 100, false, 1, 255);
try
{
offset = 0;
foreach (var p in rows)
{
DrawRowText(tex, offset, p);
offset += ROW_SIZE;
}
}
finally { tex.EndText(); }
tex.BeginText("Arial", 5, 100, false);
try
{
offset = 0;
foreach (var p in rows)
{
DrawRowTextSmall(tex, offset, p);
offset += ROW_SIZE;
}
}
finally { tex.EndText(); }
}
finally
{
tex.EndRender();
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay render error: {ex.Message}");
}
}
private static double Ratio(int cur, int max)
{
if (max <= 0) return 0;
var r = (double)cur / max;
if (r < 0) return 0;
if (r > 1) return 1;
return r;
}
private static void DrawRowBackground(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
{
var area = new Rectangle(1, offset + 1, HUD_WIDTH - 2, ROW_SIZE - 2);
double hp = Ratio(p.CurrentHealth, p.MaxHealth);
double sta = Ratio(p.CurrentStamina, p.MaxStamina);
double mana = Ratio(p.CurrentMana, p.MaxMana);
// Health bar (full row width, top half)
tex.Fill(new Rectangle(area.Left, area.Top, CHAR_NAME_WIDTH, HEALTH_BAR_HEIGHT), COLOR_HP_BG);
tex.Fill(new Rectangle(area.Left, area.Top, (int)(CHAR_NAME_WIDTH * hp), HEALTH_BAR_HEIGHT), COLOR_HP_FILL);
// Stamina bar (left half, bottom)
int halfW = CHAR_NAME_WIDTH / 2;
int bottomH = area.Height - HEALTH_BAR_HEIGHT;
tex.Fill(new Rectangle(area.Left, area.Top + HEALTH_BAR_HEIGHT, halfW, bottomH), COLOR_STA_BG);
tex.Fill(new Rectangle(area.Left, area.Top + HEALTH_BAR_HEIGHT, (int)(halfW * sta), bottomH), COLOR_STA_FILL);
// Mana bar (right half, bottom)
tex.Fill(new Rectangle(area.Left + halfW, area.Top + HEALTH_BAR_HEIGHT, halfW, bottomH), COLOR_MANA_BG);
tex.Fill(new Rectangle(area.Left + halfW, area.Top + HEALTH_BAR_HEIGHT, (int)(halfW * mana), bottomH), COLOR_MANA_FILL);
// Outer border
var tl = new PointF(area.Left, area.Top);
var tr = new PointF(area.Left + CHAR_NAME_WIDTH, area.Top);
var br = new PointF(area.Left + CHAR_NAME_WIDTH, area.Top + area.Height);
var bl = new PointF(area.Left, area.Top + area.Height);
tex.DrawLine(tl, tr, COLOR_BORDER, 1);
tex.DrawLine(tr, br, COLOR_BORDER, 1);
tex.DrawLine(br, bl, COLOR_BORDER, 1);
tex.DrawLine(bl, tl, COLOR_BORDER, 1);
}
private static void DrawRowText(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
{
var area = new Rectangle(0, offset, HUD_WIDTH, ROW_SIZE);
// name (top-left)
tex.WriteText(p.CharacterName, COLOR_TEXT, WriteTextFormats.SingleLine,
new Rectangle(area.X + 4, area.Y + 1, CHAR_NAME_WIDTH, ROW_SIZE));
// hp % (top-right)
int hpPct = p.MaxHealth > 0 ? (int)Math.Round(100.0 * p.CurrentHealth / p.MaxHealth) : 0;
tex.WriteText($"{hpPct}%", COLOR_TEXT, WriteTextFormats.Right,
new Rectangle(area.X + 4, area.Y + 1, CHAR_NAME_WIDTH - 8, ROW_SIZE));
}
private static void DrawRowTextSmall(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
{
var area = new Rectangle(0, offset, HUD_WIDTH, ROW_SIZE);
int staPct = p.MaxStamina > 0 ? (int)Math.Round(100.0 * p.CurrentStamina / p.MaxStamina) : 0;
int manaPct = p.MaxMana > 0 ? (int)Math.Round(100.0 * p.CurrentMana / p.MaxMana) : 0;
// stamina % bottom-left
tex.WriteText($"{staPct}%", COLOR_TEXT_DARK, WriteTextFormats.SingleLine,
new Rectangle(area.X + 4, area.Y + HEALTH_BAR_HEIGHT, CHAR_NAME_WIDTH, ROW_SIZE));
// mana % bottom-right-half
tex.WriteText($"{manaPct}%", COLOR_TEXT_DARK, WriteTextFormats.SingleLine,
new Rectangle(area.X + 4 + (CHAR_NAME_WIDTH / 2), area.Y + HEALTH_BAR_HEIGHT, CHAR_NAME_WIDTH, ROW_SIZE));
}
}
}