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:
parent
d220970130
commit
62a9067125
4 changed files with 293 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"] ?? "";
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue