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() + { + } + } +}