Shared-vitals overlay on a canvas
The original's DxHud overlay: the player's row, then one per character heard from, each a health bar over split stamina and mana bars with percentages, and for the others an arrow toward them relative to where the player faces plus the distance, fading from white to red as they get further. Ctrl shows a frame and a close box; Ctrl+drag moves it and the spot is saved per character. /mm vitalsharing overlay toggles it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
78813cf861
commit
0efef6170c
3 changed files with 352 additions and 0 deletions
|
|
@ -35,5 +35,6 @@ internal static class FeatureCatalog
|
||||||
features.Add(sharing);
|
features.Add(sharing);
|
||||||
session.AddConnectionListener(sharing);
|
session.AddConnectionListener(sharing);
|
||||||
session.AddShareHandler(sharing);
|
session.AddShareHandler(sharing);
|
||||||
|
features.Add(new Sharing.VitalSharingOverlay(context, sharing));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
252
src/OpenAC.MosswartMassacre/Sharing/VitalSharingOverlay.cs
Normal file
252
src/OpenAC.MosswartMassacre/Sharing/VitalSharingOverlay.cs
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Sharing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<Row>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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<VitalSharing>();
|
||||||
|
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<VitalSharingOverlay>().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<VitalSharingOverlay>().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<string> 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue