diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index ea14bac..6a6fd9c 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -35,5 +35,6 @@ internal static class FeatureCatalog
features.Add(sharing);
session.AddConnectionListener(sharing);
session.AddShareHandler(sharing);
+ features.Add(new Sharing.VitalSharingOverlay(context, sharing));
}
}
diff --git a/src/OpenAC.MosswartMassacre/Sharing/VitalSharingOverlay.cs b/src/OpenAC.MosswartMassacre/Sharing/VitalSharingOverlay.cs
new file mode 100644
index 0000000..901774c
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Sharing/VitalSharingOverlay.cs
@@ -0,0 +1,252 @@
+using System.Globalization;
+using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Infrastructure;
+
+namespace OpenAC.MosswartMassacre.Sharing;
+
+///
+/// The shared-vitals overlay: one row per character, the player first, each
+/// with a health bar above split stamina and mana bars, and for the others an
+/// arrow pointing their way and the distance in metres. Ctrl+drag moves it;
+/// Ctrl shows a close box. Graphical host only.
+///
+internal sealed class VitalSharingOverlay : IMmFeature
+{
+ private const int RowSize = 20;
+ private const int NameWidth = 180;
+ private const int HealthBarHeight = 11;
+ private const int RangeWidth = 34;
+ private const int Padding = 4;
+ private const int HudWidth = NameWidth + Padding + RowSize + RangeWidth + Padding;
+ private const int MaxRows = 16;
+ private const int HudHeight = (MaxRows * RowSize) + 5;
+ private const int CloseSize = 14;
+ private const byte Opacity = 200;
+
+ private static readonly PluginColor HpFill = new(255, 68, 68, Opacity);
+ private static readonly PluginColor HpBack = new(60, 0, 0, Opacity);
+ private static readonly PluginColor StaminaFill = new(255, 170, 0, Opacity);
+ private static readonly PluginColor StaminaBack = new(70, 30, 0, Opacity);
+ private static readonly PluginColor ManaFill = new(68, 136, 255, Opacity);
+ private static readonly PluginColor ManaBack = new(0, 20, 55, Opacity);
+ private static readonly PluginColor Text = new(255, 255, 255);
+ private static readonly PluginColor TextDark = new(0, 0, 0);
+ private static readonly PluginColor Border = new(255, 255, 0);
+ private static readonly PluginColor CloseFill = new(160, 20, 20, 220);
+
+ private readonly MmContext _context;
+ private readonly VitalSharing _sharing;
+ private IPluginCanvas? _canvas;
+ private IDisposable? _repaint;
+ private bool _controlHeld;
+ private PluginPoint? _dragFrom;
+
+ public VitalSharingOverlay(MmContext context, VitalSharing sharing)
+ {
+ _context = context;
+ _sharing = sharing;
+ sharing.OverlayToggle = Toggle;
+ }
+
+ public void OnLogin()
+ {
+ if (!_context.Host.HasUi || _canvas is not null)
+ return;
+ var settings = _context.Settings.Current;
+ _canvas = _context.Host.Ui.RegisterCanvas(
+ new PluginCanvasDescriptor("vital-sharing", HudWidth, HudHeight)
+ {
+ Anchor = PluginCanvasAnchor.TopLeft,
+ Offset = new PluginPoint(settings.VitalSharingOverlayX, settings.VitalSharingOverlayY),
+ StartVisible = settings.VitalSharingOverlayVisible,
+ AcceptsPointerInput = true,
+ },
+ Paint);
+ _canvas.PointerHandler = OnPointer;
+ _repaint = _context.Scheduler.Every(TimeSpan.FromMilliseconds(250), () => _canvas?.Invalidate());
+ }
+
+ public void OnLogoff()
+ {
+ _repaint?.Dispose();
+ _repaint = null;
+ _canvas?.Dispose();
+ _canvas = null;
+ }
+
+ public void Dispose() => OnLogoff();
+
+ public void Toggle()
+ {
+ var settings = _context.Settings.Current;
+ settings.VitalSharingOverlayVisible = !settings.VitalSharingOverlayVisible;
+ _context.Settings.Save();
+ if (_canvas is not null)
+ _canvas.IsVisible = settings.VitalSharingOverlayVisible;
+ _context.Chat.Write($"[VitalShare] Overlay {(settings.VitalSharingOverlayVisible ? "shown" : "hidden")}");
+ }
+
+ // ── Painting ──
+
+ internal void Paint(IPluginPainter painter)
+ {
+ painter.Clear(PluginColor.Transparent);
+ if (!_sharing.IsActive)
+ return;
+
+ PluginNavigationPosition here = _context.Automation.Navigation.Snapshot.Position;
+ MmCoordinates me = MmCoordinates.From(here);
+ var character = _context.Automation.Character;
+ var rows = new List
+ {
+ new(_context.CharacterName,
+ (int)character.CurrentHealth, (int)character.MaxHealth,
+ (int)character.CurrentStamina, (int)character.MaxStamina,
+ (int)character.CurrentMana, (int)character.MaxMana,
+ null),
+ };
+ foreach (Peer peer in _sharing.Peers.Current(_context.Clock.UtcNow))
+ {
+ if (rows.Count >= MaxRows)
+ break;
+ rows.Add(new Row(peer.CharacterName,
+ peer.CurrentHealth, peer.MaxHealth,
+ peer.CurrentStamina, peer.MaxStamina,
+ peer.CurrentMana, peer.MaxMana,
+ peer));
+ }
+
+ int offset = 0;
+ foreach (Row row in rows)
+ {
+ DrawRow(painter, offset, row, me, here.HeadingDegrees);
+ offset += RowSize;
+ }
+
+ if (_controlHeld)
+ {
+ int height = (rows.Count * RowSize) + 5;
+ painter.StrokeRect(new PluginRect(0, 0, HudWidth, height), Border);
+ PluginRect close = CloseRect;
+ painter.FillRect(close, CloseFill);
+ painter.StrokeRect(close, PluginColor.White);
+ painter.DrawLine(new PluginPoint(close.X + 3, close.Y + 3),
+ new PluginPoint(close.X + close.Width - 3, close.Y + close.Height - 3), PluginColor.White);
+ painter.DrawLine(new PluginPoint(close.X + close.Width - 3, close.Y + 3),
+ new PluginPoint(close.X + 3, close.Y + close.Height - 3), PluginColor.White);
+ }
+ }
+
+ private static PluginRect CloseRect => new(HudWidth - CloseSize - 2, 2, CloseSize, CloseSize);
+
+ private static void DrawRow(IPluginPainter painter, int offset, Row row, MmCoordinates me, double myHeading)
+ {
+ double hp = Fraction(row.Health, row.MaxHealth);
+ double stamina = Fraction(row.Stamina, row.MaxStamina);
+ double mana = Fraction(row.Mana, row.MaxMana);
+ double left = 1, top = offset + 1;
+
+ painter.FillRect(new PluginRect(left, top, NameWidth, HealthBarHeight), HpBack);
+ painter.FillRect(new PluginRect(left, top, (int)(NameWidth * hp), HealthBarHeight), HpFill);
+ int half = NameWidth / 2;
+ int bottom = RowSize - 2 - HealthBarHeight;
+ painter.FillRect(new PluginRect(left, top + HealthBarHeight, half, bottom), StaminaBack);
+ painter.FillRect(new PluginRect(left, top + HealthBarHeight, (int)(half * stamina), bottom), StaminaFill);
+ painter.FillRect(new PluginRect(left + half, top + HealthBarHeight, half, bottom), ManaBack);
+ painter.FillRect(new PluginRect(left + half, top + HealthBarHeight, (int)(half * mana), bottom), ManaFill);
+
+ painter.DrawText(row.Name, new PluginPoint(4, offset + 1), Text, outline: true);
+ string hpText = Percent(row.Health, row.MaxHealth);
+ PluginSize hpSize = painter.MeasureText(hpText);
+ painter.DrawText(hpText, new PluginPoint(NameWidth - 2 - hpSize.Width, offset + 1), Text, outline: true);
+
+ if (row.Peer is not { } peer)
+ return;
+
+ if (!peer.HasPosition)
+ {
+ painter.DrawText("??", new PluginPoint(NameWidth + Padding + RowSize, offset + 5), new PluginColor(128, 128, 128));
+ return;
+ }
+
+ double dx = peer.EW - me.EW;
+ double dy = peer.NS - me.NS;
+ double distance = Math.Sqrt((dx * dx) + (dy * dy)) * 240.0;
+ int fade = (int)Math.Max(0, Math.Min(255, 255 - (distance * 2)));
+ var tint = new PluginColor(255, (byte)fade, (byte)fade);
+ painter.DrawText(FormatDistance(distance), new PluginPoint(NameWidth + Padding + RowSize, offset + 5), tint);
+ if (distance < 0.1)
+ return;
+
+ // Angle from north, clockwise, relative to where the player faces.
+ double relative = (Math.Atan2(dx, dy) * 180.0 / Math.PI) - myHeading;
+ double rad = relative * Math.PI / 180.0;
+ double cx = NameWidth + Padding + (RowSize / 2d);
+ double cy = offset + (RowSize / 2d);
+ double size = RowSize * 0.35;
+ var tip = new PluginPoint(cx + (Math.Sin(rad) * size), cy - (Math.Cos(rad) * size));
+ double leftRad = rad + (140.0 * Math.PI / 180.0);
+ double rightRad = rad - (140.0 * Math.PI / 180.0);
+ var leftCorner = new PluginPoint(cx + (Math.Sin(leftRad) * size * 0.75), cy - (Math.Cos(leftRad) * size * 0.75));
+ var rightCorner = new PluginPoint(cx + (Math.Sin(rightRad) * size * 0.75), cy - (Math.Cos(rightRad) * size * 0.75));
+ painter.DrawLine(tip, leftCorner, tint, 2);
+ painter.DrawLine(leftCorner, rightCorner, tint, 2);
+ painter.DrawLine(rightCorner, tip, tint, 2);
+ }
+
+ private static double Fraction(int current, int maximum) =>
+ maximum > 0 ? Math.Clamp((double)current / maximum, 0, 1) : 0;
+
+ private static string Percent(int current, int maximum) =>
+ (maximum > 0 ? (int)Math.Round(100.0 * current / maximum) : 0).ToString(CultureInfo.CurrentCulture) + "%";
+
+ internal static string FormatDistance(double meters)
+ {
+ if (meters < 1000)
+ return meters.ToString("N0", CultureInfo.CurrentCulture);
+ if (meters < 10000)
+ return (meters / 1000).ToString("N1", CultureInfo.CurrentCulture) + "k";
+ return (meters / 1000).ToString("N0", CultureInfo.CurrentCulture) + "k";
+ }
+
+ // ── Pointer: Ctrl shows the frame and close box; Ctrl+drag moves ──
+
+ internal void OnPointer(PluginPointerEvent pointer)
+ {
+ if (_canvas is null)
+ return;
+ _controlHeld = (pointer.Modifiers & PluginKeyModifiers.Control) != 0;
+ switch (pointer.Kind)
+ {
+ case PluginPointerEventKind.Down when _controlHeld && pointer.Button == PluginPointerButton.Left:
+ if (Contains(CloseRect, pointer.Position))
+ {
+ Toggle();
+ return;
+ }
+ _dragFrom = pointer.Position;
+ break;
+ case PluginPointerEventKind.Move when _dragFrom is { } from:
+ var offset = _canvas.Offset;
+ _canvas.Offset = new PluginPoint(
+ offset.X + (pointer.Position.X - from.X),
+ offset.Y + (pointer.Position.Y - from.Y));
+ break;
+ case PluginPointerEventKind.Up or PluginPointerEventKind.Cancelled when _dragFrom is not null:
+ _dragFrom = null;
+ _canvas.ReleasePointer();
+ _context.Settings.Current.VitalSharingOverlayX = (int)_canvas.Offset.X;
+ _context.Settings.Current.VitalSharingOverlayY = (int)_canvas.Offset.Y;
+ _context.Settings.Save();
+ break;
+ }
+ _canvas.Invalidate();
+ }
+
+ private static bool Contains(PluginRect rect, PluginPoint point) =>
+ point.X >= rect.X && point.X < rect.X + rect.Width
+ && point.Y >= rect.Y && point.Y < rect.Y + rect.Height;
+
+ private sealed record Row(
+ string Name, int Health, int MaxHealth, int Stamina, int MaxStamina, int Mana, int MaxMana, Peer? Peer);
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingOverlayTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingOverlayTests.cs
new file mode 100644
index 0000000..70b9f71
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingOverlayTests.cs
@@ -0,0 +1,99 @@
+using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Sharing;
+using OpenAC.MosswartMassacre.Tests.Streams;
+
+namespace OpenAC.MosswartMassacre.Tests.Sharing;
+
+public sealed class VitalSharingOverlayTests
+{
+ [Fact]
+ public void Paints_the_player_then_each_peer_with_distance()
+ {
+ using var harness = new PluginHarness("Mossy");
+ harness.Host.Automation.Character.CurrentHealth = 50;
+ harness.Host.Automation.Character.MaxHealth = 100;
+ harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot(
+ true, false, 1u, StreamGoldenTests.Position(), false, false);
+ harness.Login();
+ var sharing = harness.Feature();
+ sharing.Start();
+ harness.Receive("{\"type\":\"share_position_update\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"ew\":" + ((double)33.65f + (100.0 / 240.0)).ToString(System.Globalization.CultureInfo.InvariantCulture)
+ + ",\"ns\":" + ((double)42.55f).ToString(System.Globalization.CultureInfo.InvariantCulture)
+ + ",\"z\":0,\"heading\":0}");
+ harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\",\"player_id\":1342177282,"
+ + "\"current_health\":10,\"max_health\":100}");
+ var painter = new RecordingPainter();
+
+ harness.Feature().Paint(painter);
+
+ Assert.Equal(["Mossy", "50%", "Horan", "10%", "100"], painter.Texts);
+ Assert.Equal(3, painter.Lines);
+ }
+
+ [Fact]
+ public void Nothing_is_painted_while_sharing_is_off()
+ {
+ using var harness = new PluginHarness("Mossy");
+ harness.Login();
+ var painter = new RecordingPainter();
+
+ harness.Feature().Paint(painter);
+
+ Assert.Empty(painter.Texts);
+ }
+
+ [Fact]
+ public void Distances_read_as_the_original_wrote_them()
+ {
+ Assert.Equal((12).ToString("N0"), VitalSharingOverlay.FormatDistance(12.3));
+ Assert.Equal((2.5).ToString("N1") + "k", VitalSharingOverlay.FormatDistance(2500));
+ Assert.Equal((25).ToString("N0") + "k", VitalSharingOverlay.FormatDistance(25000));
+ }
+
+ private sealed class RecordingPainter : IPluginPainter
+ {
+ public List Texts { get; } = [];
+
+ public int Lines { get; private set; }
+
+ public int Width => 300;
+
+ public int Height => 300;
+
+ public void Clear(PluginColor color)
+ {
+ }
+
+ public void FillRect(PluginRect rect, PluginColor color)
+ {
+ }
+
+ public void StrokeRect(PluginRect rect, PluginColor color, float thickness = 1f)
+ {
+ }
+
+ public void DrawLine(PluginPoint from, PluginPoint to, PluginColor color, float thickness = 1f) => Lines++;
+
+ public void DrawText(string text, PluginPoint position, PluginColor color, bool outline = false) => Texts.Add(text);
+
+ public PluginSize MeasureText(string text) => new(text.Length * 6, 12);
+
+ public void DrawImage(PluginImage image, PluginRect destination, PluginColor tint)
+ {
+ }
+
+ public void DrawImageTransformed(PluginImage image, PluginRect destination, PluginColor tint,
+ double rotationRadians, PluginPoint pivot, double scaleX = 1.0, double scaleY = 1.0)
+ {
+ }
+
+ public void PushClip(PluginRect rect)
+ {
+ }
+
+ public void PopClip()
+ {
+ }
+ }
+}