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);
|
||||
session.AddConnectionListener(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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue