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>
This commit is contained in:
Erik 2026-04-11 14:40:34 +02:00
parent 29a54b720f
commit d220970130
5 changed files with 248 additions and 123 deletions

View file

@ -367,7 +367,6 @@
<EmbeddedResource Include="ViewXML\flagTracker.xml" /> <EmbeddedResource Include="ViewXML\flagTracker.xml" />
<EmbeddedResource Include="ViewXML\mainView.xml" /> <EmbeddedResource Include="ViewXML\mainView.xml" />
<EmbeddedResource Include="ViewXML\mainViewTabbed.xml" /> <EmbeddedResource Include="ViewXML\mainViewTabbed.xml" />
<EmbeddedResource Include="ViewXML\vitalSharingOverlay.xml" />
<EmbeddedResource Include="..\Shared\Spells\Spells.csv"> <EmbeddedResource Include="..\Shared\Spells\Spells.csv">
<Link>Shared\Spells\Spells.csv</Link> <Link>Shared\Spells\Spells.csv</Link>
</EmbeddedResource> </EmbeddedResource>

View file

@ -1,11 +0,0 @@
<?xml version="1.0"?>
<view icon="7735" title="Vital Sharing" width="360" height="260">
<control progid="DecalControls.FixedLayout" clipped="">
<control progid="DecalControls.StaticText" name="lblHeader" left="8" top="6" width="340" height="16" text="Vital Sharing Peers" style="FontBold"/>
<control progid="DecalControls.StaticText" name="lblCol0" left="8" top="24" width="160" height="14" text="Character"/>
<control progid="DecalControls.StaticText" name="lblCol1" left="170" top="24" width="55" height="14" text="HP"/>
<control progid="DecalControls.StaticText" name="lblCol2" left="227" top="24" width="55" height="14" text="Stam"/>
<control progid="DecalControls.StaticText" name="lblCol3" left="284" top="24" width="55" height="14" text="Mana"/>
<control progid="DecalControls.List" name="lstPeers" left="6" top="42" width="340" height="200"/>
</control>
</view>

View file

@ -1,53 +1,57 @@
using System; using System;
using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using Decal.Adapter;
using VirindiViewService; using VirindiViewService;
using VirindiViewService.Controls;
namespace MosswartMassacre.Views namespace MosswartMassacre.Views
{ {
/// <summary> /// <summary>
/// In-game overlay window showing live vitals for every character that has /// In-game vital sharing overlay — a DxHud drawn directly onto the game
/// opted-in to vital sharing via MosswartOverlord. Modeled after UB's /// surface so the visual matches UtilityBelt's NetworkUI (DrawCharNameBackground).
/// NetworkUI — one row per peer with HP/Stamina/Mana percentages coloured /// One row per peer (including the local character), showing name, HP bar
/// to match the web sidebar palette. /// 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> /// </summary>
internal class VitalSharingOverlayView : VVSBaseView internal static class VitalSharingOverlayView
{ {
private static VitalSharingOverlayView _instance; // 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;
private HudList _peerList; // Colors matched to MosswartOverlord player sidebar (#ff4444 / #ffaa00 / #4488ff)
private System.Timers.Timer _refreshTimer; 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);
// Colors matched to MosswartOverlord player sidebar private static DxHud _hud;
// health: #ff4444, stamina: #ffaa00, mana: #4488ff private static System.Windows.Forms.Timer _redrawTimer;
private static readonly Color COLOR_HP = Color.FromArgb(255, 68, 68); private static bool _visible;
private static readonly Color COLOR_STA = Color.FromArgb(255, 170, 0); private static int _lastRowCount;
private static readonly Color COLOR_MANA = Color.FromArgb(68, 136, 255);
private static readonly Color COLOR_NAME = Color.White;
private VitalSharingOverlayView() : base(null) public static bool IsVisible => _visible;
{
CreateFromXMLResource("MosswartMassacre.ViewXML.vitalSharingOverlay.xml");
Init();
}
public static void ToggleWindow() public static void ToggleWindow()
{ {
try try
{ {
if (_instance == null) if (_visible) Hide();
{ else Show();
_instance = new VitalSharingOverlayView();
_instance.Show();
}
else if (_instance.IsVisible)
{
_instance.Hide();
}
else
{
_instance.Show();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -55,117 +59,226 @@ namespace MosswartMassacre.Views
} }
} }
public static void ForceClose() public static void Show()
{ {
try try
{ {
_instance?.Dispose(); if (_visible && _hud != null)
_instance = null;
}
catch { }
}
private void Init()
{
try
{
_peerList = GetControl<HudList>("lstPeers");
if (_peerList != null)
{ {
// columns: name, hp%, sta%, mana% _hud.Enabled = true;
_peerList.AddColumn(typeof(HudStaticText), 160, null); return;
_peerList.AddColumn(typeof(HudStaticText), 55, null);
_peerList.AddColumn(typeof(HudStaticText), 55, null);
_peerList.AddColumn(typeof(HudStaticText), 55, null);
} }
_refreshTimer = new System.Timers.Timer(500); var rows = Math.Max(1, CountRows());
_refreshTimer.AutoReset = true; var size = new Size(HUD_WIDTH, rows * ROW_SIZE + 5);
_refreshTimer.Elapsed += (s, e) => _hud = new DxHud(new Point(HUD_X_OFFSET, HUD_Y_OFFSET), size, 0)
{ {
try { RefreshPeers(); } Enabled = true,
catch (Exception ex) { PluginCore.WriteToChat($"[VitalShare] Overlay refresh error: {ex.Message}"); } Alpha = 255,
}; };
_refreshTimer.Start(); _visible = true;
_lastRowCount = rows;
RefreshPeers(); 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) catch (Exception ex)
{ {
PluginCore.WriteToChat($"[VitalShare] Overlay init error: {ex.Message}"); PluginCore.WriteToChat($"[VitalShare] Overlay show error: {ex.Message}");
} }
} }
private void RefreshPeers() public static void Hide()
{ {
if (_peerList == null) return; try
var snaps = VitalSharingTracker.GetPeerSnapshots();
_peerList.ClearRows();
if (snaps == null || snaps.Length == 0)
{ {
var row = _peerList.AddRow(); _redrawTimer?.Stop();
((HudStaticText)row[0]).Text = "(no peers)"; if (_hud != null)
((HudStaticText)row[0]).TextColor = Color.Gray; {
((HudStaticText)row[1]).Text = ""; _hud.Enabled = false;
((HudStaticText)row[2]).Text = ""; _hud.Dispose();
((HudStaticText)row[3]).Text = ""; _hud = null;
return; }
_visible = false;
} }
catch (Exception ex)
Array.Sort(snaps, (a, b) =>
string.Compare(a.CharacterName, b.CharacterName, StringComparison.OrdinalIgnoreCase));
foreach (var p in snaps)
{ {
var row = _peerList.AddRow(); PluginCore.WriteToChat($"[VitalShare] Overlay hide error: {ex.Message}");
var name = (HudStaticText)row[0];
name.Text = p.CharacterName;
name.TextColor = COLOR_NAME;
var hp = (HudStaticText)row[1];
hp.Text = FormatPct(p.CurrentHealth, p.MaxHealth);
hp.TextColor = COLOR_HP;
var sta = (HudStaticText)row[2];
sta.Text = FormatPct(p.CurrentStamina, p.MaxStamina);
sta.TextColor = COLOR_STA;
var mana = (HudStaticText)row[3];
mana.Text = FormatPct(p.CurrentMana, p.MaxMana);
mana.TextColor = COLOR_MANA;
} }
} }
private static string FormatPct(int cur, int max) private static int CountRows()
{ {
if (max <= 0) return "--"; int n = VitalSharingTracker.GetPeerSnapshots()?.Length ?? 0;
int pct = (int)Math.Round(100.0 * cur / max); // self is always drawn as the first row
if (pct < 0) pct = 0; return n + 1;
if (pct > 100) pct = 100;
return pct + "%";
} }
protected override void Dispose(bool disposing) private static List<VitalSharingTracker.PeerSnapshot> BuildRows()
{ {
if (disposing) 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 try
{ {
if (_refreshTimer != null) // 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)
{ {
_refreshTimer.Stop(); DrawRowBackground(tex, offset, p);
_refreshTimer.Dispose(); offset += ROW_SIZE;
_refreshTimer = null;
} }
// 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 { }
} }
base.Dispose(disposing); catch (Exception ex)
if (_instance == this) _instance = null; {
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));
} }
} }
} }

View file

@ -85,6 +85,30 @@ namespace MosswartMassacre
} }
} }
/// <summary>
/// Snapshot for the local character (first row in the overlay so you
/// always see yourself even before any peer traffic arrives).
/// </summary>
public static PeerSnapshot GetSelfSnapshot()
{
try
{
var actions = CoreManager.Current.Actions;
return new PeerSnapshot
{
CharacterName = CoreManager.Current.CharacterFilter?.Name ?? "",
CurrentHealth = actions.Vital[VitalType.CurrentHealth],
MaxHealth = actions.Vital[VitalType.MaximumHealth],
CurrentStamina = actions.Vital[VitalType.CurrentStamina],
MaxStamina = actions.Vital[VitalType.MaximumStamina],
CurrentMana = actions.Vital[VitalType.CurrentMana],
MaxMana = actions.Vital[VitalType.MaximumMana],
LastUpdate = DateTime.UtcNow,
};
}
catch { return null; }
}
public VitalSharingTracker(IPluginLogger logger) public VitalSharingTracker(IPluginLogger logger)
{ {
_logger = logger; _logger = logger;