feat(vitalsharing): direction arrow, range text, Ctrl+drag overlay

Mirrors UtilityBelt NetworkUI behaviour for the cross-PC vital sharing
overlay:

- Each non-self row now draws a small triangle pointing toward the peer
  (relative to the local character's facing) and a range label in meters,
  both tinted red as distance grows. Uses share_position_update data
  that was already being streamed but previously ignored on receive.
- VitalSharingTracker caches peer positions from share_position_update
  into the same PeerSnapshot used by the overlay.
- Hold Ctrl and left-drag the overlay to reposition it. A yellow border
  highlights the drag bounds while Ctrl is held (matches UB).
  Position is persisted to PluginSettings.VitalSharingOverlayX/Y.
- Input handled via CoreManager.WindowMessage, eating the events so the
  game doesn't also react to the drag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:49:54 +02:00
parent d220970130
commit 62a9067125
4 changed files with 293 additions and 24 deletions

View file

@ -25,6 +25,8 @@ namespace MosswartMassacre
private bool _verboseLogging = false;
private bool _autoUpdateEnabled = true;
private bool _vitalSharingEnabled = false;
private int _vitalSharingOverlayX = 50;
private int _vitalSharingOverlayY = 200;
private ChestLooterSettings _chestLooterSettings = new ChestLooterSettings();
public static PluginSettings Instance => _instance
@ -204,6 +206,18 @@ namespace MosswartMassacre
set { _vitalSharingEnabled = value; Save(); }
}
public int VitalSharingOverlayX
{
get => _vitalSharingOverlayX;
set { _vitalSharingOverlayX = value; Save(); }
}
public int VitalSharingOverlayY
{
get => _vitalSharingOverlayY;
set { _vitalSharingOverlayY = value; Save(); }
}
public ChestLooterSettings ChestLooterSettings
{
get

View file

@ -8,24 +8,25 @@ 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).
/// surface so the visual matches UtilityBelt's NetworkUI
/// (DrawCharNameBackground / DrawRangeIndicator). One row per peer
/// (plus the local character), showing character name, direction arrow
/// pointing at the peer, distance in meters, HP bar across the full
/// name area, and Stamina + Mana bars side-by-side on the bottom half.
/// Colours match the MosswartOverlord player sidebar palette.
///
/// Toggle with /mm vitalsharing overlay, via the Settings tab button, or
/// VitalSharingOverlayView.ToggleWindow().
/// Hold Ctrl + left-drag to reposition the overlay (same behaviour as
/// UB's NetworkUI). The position is persisted to plugin settings.
/// </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 CHAR_NAME_WIDTH = 180;
private const int HEALTH_BAR_HEIGHT = 11;
private const int RANGE_WIDTH = 34;
private const int PADDING = 4;
private const int HUD_WIDTH = CHAR_NAME_WIDTH + PADDING + ROW_SIZE + RANGE_WIDTH + PADDING;
// Colors matched to MosswartOverlord player sidebar (#ff4444 / #ffaa00 / #4488ff)
private const int HUD_OPACITY = 200;
@ -38,12 +39,20 @@ namespace MosswartMassacre.Views
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 readonly Color COLOR_DRAG_BORDER = Color.Yellow;
private static DxHud _hud;
private static System.Windows.Forms.Timer _redrawTimer;
private static bool _visible;
private static int _lastRowCount;
// Drag state
private static bool _isHoldingControl;
private static bool _isDragging;
private static Point _dragStart;
private static Point _dragBaseHudPos;
private static bool _windowMessageHooked;
public static bool IsVisible => _visible;
public static void ToggleWindow()
@ -71,7 +80,10 @@ namespace MosswartMassacre.Views
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)
var loc = new Point(
PluginSettings.Instance.VitalSharingOverlayX,
PluginSettings.Instance.VitalSharingOverlayY);
_hud = new DxHud(loc, size, 0)
{
Enabled = true,
Alpha = 255,
@ -87,6 +99,7 @@ namespace MosswartMassacre.Views
}
_redrawTimer.Start();
HookWindowMessages();
Render();
}
catch (Exception ex)
@ -100,6 +113,7 @@ namespace MosswartMassacre.Views
try
{
_redrawTimer?.Stop();
UnhookWindowMessages();
if (_hud != null)
{
_hud.Enabled = false;
@ -107,6 +121,7 @@ namespace MosswartMassacre.Views
_hud = null;
}
_visible = false;
_isDragging = false;
}
catch (Exception ex)
{
@ -114,11 +129,116 @@ namespace MosswartMassacre.Views
}
}
// ---------- Window message / drag handling ----------
private static void HookWindowMessages()
{
if (_windowMessageHooked) return;
try
{
CoreManager.Current.WindowMessage += OnWindowMessage;
_windowMessageHooked = true;
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Hook error: {ex.Message}");
}
}
private static void UnhookWindowMessages()
{
if (!_windowMessageHooked) return;
try
{
CoreManager.Current.WindowMessage -= OnWindowMessage;
}
catch { }
_windowMessageHooked = false;
_isHoldingControl = false;
_isDragging = false;
}
// Window message constants (from winuser.h)
private const int WM_KEYDOWN = 0x0100;
private const int WM_KEYUP = 0x0101;
private const int WM_MOUSEMOVE = 0x0200;
private const int WM_LBUTTONDOWN = 0x0201;
private const int WM_LBUTTONUP = 0x0202;
private const int VK_CONTROL = 0x11;
private static Rectangle HudRect()
{
if (_hud == null) return Rectangle.Empty;
return new Rectangle(_hud.Location.X, _hud.Location.Y, HUD_WIDTH, Math.Max(1, _lastRowCount) * ROW_SIZE + 5);
}
private static void OnWindowMessage(object sender, WindowMessageEventArgs e)
{
try
{
if (!_visible || _hud == null) return;
switch (e.Msg)
{
case WM_KEYDOWN:
if ((int)e.WParam == VK_CONTROL) _isHoldingControl = true;
return;
case WM_KEYUP:
if ((int)e.WParam == VK_CONTROL)
{
_isHoldingControl = false;
_isDragging = false;
}
return;
}
if (!_isHoldingControl) return;
var mouse = new Point(e.LParam);
switch (e.Msg)
{
case WM_LBUTTONDOWN:
if (HudRect().Contains(mouse))
{
_isDragging = true;
_dragStart = mouse;
_dragBaseHudPos = _hud.Location;
e.Eat = true;
}
break;
case WM_MOUSEMOVE:
if (_isDragging)
{
int nx = _dragBaseHudPos.X + (mouse.X - _dragStart.X);
int ny = _dragBaseHudPos.Y + (mouse.Y - _dragStart.Y);
_hud.Location = new Point(nx, ny);
e.Eat = true;
}
break;
case WM_LBUTTONUP:
if (_isDragging)
{
_isDragging = false;
PluginSettings.Instance.VitalSharingOverlayX = _hud.Location.X;
PluginSettings.Instance.VitalSharingOverlayY = _hud.Location.Y;
e.Eat = true;
}
break;
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] WindowMessage error: {ex.Message}");
}
}
// ---------- Row enumeration ----------
private static int CountRows()
{
int n = VitalSharingTracker.GetPeerSnapshots()?.Length ?? 0;
// self is always drawn as the first row
return n + 1;
return n + 1; // self row
}
private static List<VitalSharingTracker.PeerSnapshot> BuildRows()
@ -142,6 +262,8 @@ namespace MosswartMassacre.Views
return rows;
}
// ---------- Rendering ----------
private static void Render()
{
try
@ -151,11 +273,9 @@ namespace MosswartMassacre.Views
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)
@ -169,7 +289,6 @@ namespace MosswartMassacre.Views
tex.BeginRender();
try
{
// Clear background
tex.Fill(new Rectangle(0, 0, HUD_WIDTH, rows.Count * ROW_SIZE + 5), Color.Transparent);
int offset = 0;
@ -179,14 +298,33 @@ namespace MosswartMassacre.Views
offset += ROW_SIZE;
}
// Direction arrows (everyone except the self row)
offset = 0;
for (int i = 0; i < rows.Count; i++)
{
if (i > 0) DrawDirectionArrow(tex, offset, rows[i]);
offset += ROW_SIZE;
}
// Drag-highlight border when Ctrl is held
if (_isHoldingControl)
{
var w = HUD_WIDTH;
var h = rows.Count * ROW_SIZE + 5;
tex.DrawLine(new PointF(0, 0), new PointF(w - 1, 0), COLOR_DRAG_BORDER, 1);
tex.DrawLine(new PointF(w - 1, 0), new PointF(w - 1, h - 1), COLOR_DRAG_BORDER, 1);
tex.DrawLine(new PointF(w - 1, h - 1), new PointF(0, h - 1), COLOR_DRAG_BORDER, 1);
tex.DrawLine(new PointF(0, h - 1), new PointF(0, 0), COLOR_DRAG_BORDER, 1);
}
// Text passes
tex.BeginText("Arial", 7, 100, false, 1, 255);
try
{
offset = 0;
foreach (var p in rows)
for (int i = 0; i < rows.Count; i++)
{
DrawRowText(tex, offset, p);
DrawRowText(tex, offset, rows[i], isSelf: i == 0);
offset += ROW_SIZE;
}
}
@ -226,7 +364,7 @@ namespace MosswartMassacre.Views
private static void DrawRowBackground(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
{
var area = new Rectangle(1, offset + 1, HUD_WIDTH - 2, ROW_SIZE - 2);
var area = new Rectangle(1, offset + 1, CHAR_NAME_WIDTH, ROW_SIZE - 2);
double hp = Ratio(p.CurrentHealth, p.MaxHealth);
double sta = Ratio(p.CurrentStamina, p.MaxStamina);
double mana = Ratio(p.CurrentMana, p.MaxMana);
@ -245,7 +383,7 @@ namespace MosswartMassacre.Views
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
// Outer border around the vital area
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);
@ -256,16 +394,101 @@ namespace MosswartMassacre.Views
tex.DrawLine(bl, tl, COLOR_BORDER, 1);
}
private static void DrawRowText(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
// Draw a small triangle pointing toward the peer's position relative
// to the local character, tinted red by distance.
private static void DrawDirectionArrow(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)
{
if (!p.HasPosition) return;
double selfEW, selfNS;
double myHeading;
try
{
var me = Coordinates.Me;
selfEW = me.EW;
selfNS = me.NS;
myHeading = CoreManager.Current.Actions.Heading;
}
catch { return; }
double dx = p.EW - selfEW;
double dy = p.NS - selfNS;
double distance = Math.Sqrt(dx * dx + dy * dy) * 240.0; // 1 coord = 240m
if (distance < 0.1) return;
// Angle from north (positive Y), clockwise. atan2(dx, dy) gives
// 0 when target is due north, rotating east positively.
double worldAngleDeg = Math.Atan2(dx, dy) * 180.0 / Math.PI;
double relative = worldAngleDeg - myHeading;
double rad = relative * Math.PI / 180.0;
// Arrow anchored in the small square just right of the vital area
float cx = CHAR_NAME_WIDTH + PADDING + ROW_SIZE / 2f;
float cy = offset + ROW_SIZE / 2f;
float size = ROW_SIZE * 0.35f;
// Tip in direction of `rad`, base corners 140° behind
double tipX = cx + Math.Sin(rad) * size;
double tipY = cy - Math.Cos(rad) * size;
double leftRad = rad + (140.0 * Math.PI / 180.0);
double rightRad = rad - (140.0 * Math.PI / 180.0);
double leftX = cx + Math.Sin(leftRad) * size * 0.75;
double leftY = cy - Math.Cos(leftRad) * size * 0.75;
double rightX = cx + Math.Sin(rightRad) * size * 0.75;
double rightY = cy - Math.Cos(rightRad) * size * 0.75;
int fade = (int)Math.Max(0, Math.Min(255, 255 - distance * 2));
var tint = Color.FromArgb(255, 255, fade, fade);
tex.DrawLine(new PointF((float)tipX, (float)tipY), new PointF((float)leftX, (float)leftY), tint, 2);
tex.DrawLine(new PointF((float)leftX, (float)leftY), new PointF((float)rightX, (float)rightY), tint, 2);
tex.DrawLine(new PointF((float)rightX, (float)rightY), new PointF((float)tipX, (float)tipY), tint, 2);
}
private static string FormatDistance(double meters)
{
if (meters < 1000) return meters.ToString("N0");
if (meters < 10000) return (meters / 1000).ToString("N1") + "k";
return (meters / 1000).ToString("N0") + "k";
}
private static void DrawRowText(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p, bool isSelf)
{
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)
// hp % (top-right of name area)
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));
new Rectangle(area.X + 2, area.Y + 1, CHAR_NAME_WIDTH - 4, ROW_SIZE));
// range text to the right of the arrow square
if (!isSelf)
{
if (p.HasPosition)
{
double selfEW, selfNS;
try
{
var me = Coordinates.Me;
selfEW = me.EW; selfNS = me.NS;
}
catch { return; }
double dx = p.EW - selfEW;
double dy = p.NS - selfNS;
double distance = Math.Sqrt(dx * dx + dy * dy) * 240.0;
int fade = (int)Math.Max(0, Math.Min(255, 255 - distance * 2));
var tint = Color.FromArgb(255, 255, fade, fade);
tex.WriteText(FormatDistance(distance), tint, WriteTextFormats.SingleLine,
new Rectangle(CHAR_NAME_WIDTH + PADDING + ROW_SIZE, offset + 5, RANGE_WIDTH, 12));
}
else
{
tex.WriteText("??", Color.Gray, WriteTextFormats.SingleLine,
new Rectangle(CHAR_NAME_WIDTH + PADDING + ROW_SIZE, offset + 5, RANGE_WIDTH, 12));
}
}
}
private static void DrawRowTextSmall(DxTexture tex, int offset, VitalSharingTracker.PeerSnapshot p)

View file

@ -68,6 +68,10 @@ namespace MosswartMassacre
public int CurrentHealth, MaxHealth;
public int CurrentStamina, MaxStamina;
public int CurrentMana, MaxMana;
// Position of the peer in world coordinates (from share_position_update)
public double EW, NS, Z;
public double Heading;
public bool HasPosition;
public DateTime LastUpdate;
}
@ -208,6 +212,8 @@ namespace MosswartMassacre
HandleShareCastSuccess(root);
break;
case "share_position_update":
HandleSharePositionUpdate(root);
break;
case "share_item_update":
// Dashboard informational only. No VTank feeding.
break;
@ -273,6 +279,32 @@ namespace MosswartMassacre
});
}
private void HandleSharePositionUpdate(JObject root)
{
string fromChar = (string)root["character_name"] ?? "";
if (string.IsNullOrEmpty(fromChar)) return;
if (fromChar.Equals(SafeCharacterName(), StringComparison.OrdinalIgnoreCase))
return;
double ew = (double?)root["ew"] ?? 0;
double ns = (double?)root["ns"] ?? 0;
double z = (double?)root["z"] ?? 0;
double heading = (double?)root["heading"] ?? 0;
lock (_peersLock)
{
if (!_peers.TryGetValue(fromChar, out var snap))
{
snap = new PeerSnapshot { CharacterName = fromChar };
_peers[fromChar] = snap;
}
snap.EW = ew; snap.NS = ns; snap.Z = z;
snap.Heading = heading;
snap.HasPosition = true;
snap.LastUpdate = DateTime.UtcNow;
}
}
private void HandleShareCastAttempt(JObject root)
{
string fromChar = (string)root["character_name"] ?? "";