diff --git a/MosswartMassacre/PluginSettings.cs b/MosswartMassacre/PluginSettings.cs index 90e14b1..ec5ac31 100644 --- a/MosswartMassacre/PluginSettings.cs +++ b/MosswartMassacre/PluginSettings.cs @@ -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 diff --git a/MosswartMassacre/Views/VitalSharingOverlayView.cs b/MosswartMassacre/Views/VitalSharingOverlayView.cs index b90407e..7db3439 100644 --- a/MosswartMassacre/Views/VitalSharingOverlayView.cs +++ b/MosswartMassacre/Views/VitalSharingOverlayView.cs @@ -8,24 +8,25 @@ namespace MosswartMassacre.Views { /// /// 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. /// 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 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) diff --git a/MosswartMassacre/VitalSharingTracker.cs b/MosswartMassacre/VitalSharingTracker.cs index 2a8a3fc..6a93b6b 100644 --- a/MosswartMassacre/VitalSharingTracker.cs +++ b/MosswartMassacre/VitalSharingTracker.cs @@ -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"] ?? ""; diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index 5e1fb33..70d1e1b 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ