feat(vitalsharing): settings UI, in-game overlay, reuse CharTag

- Settings tab: new "Vital Sharing (cross-PC)" checkbox + "Open Overlay"
  button, plus help text clarifying CharTag is used as the sharing group.
- Drop separate VitalSharingTags list — reuse PluginSettings.CharTag as
  the single tag value sent with every share_* payload.
- New VitalSharingOverlayView (HudList of peers with coloured HP/STA/MANA
  percentages) modeled after UB's NetworkUI. Colours match the web
  sidebar palette (#ff4444 / #ffaa00 / #4488ff).
- VitalSharingTracker now caches a peer snapshot on every inbound
  share_vital_update so the overlay can render without re-polling.
- PluginCore.SetVitalSharingEnabled static helper so the settings
  checkbox can toggle without poking at private fields.
- /mm vitalsharing command simplified to on|off|status|overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:33:17 +02:00
parent b87412e8f9
commit 29a54b720f
9 changed files with 304 additions and 81 deletions

View file

@ -350,6 +350,7 @@
<Compile Include="Views\FlagTrackerView.cs" />
<Compile Include="Views\VVSBaseView.cs" />
<Compile Include="Views\VVSTabbedMainView.cs" />
<Compile Include="Views\VitalSharingOverlayView.cs" />
<Compile Include="CharacterStats.cs" />
<Compile Include="DungeonMapReader.cs" />
<Compile Include="NearbyObjectsTracker.cs" />
@ -366,6 +367,7 @@
<EmbeddedResource Include="ViewXML\flagTracker.xml" />
<EmbeddedResource Include="ViewXML\mainView.xml" />
<EmbeddedResource Include="ViewXML\mainViewTabbed.xml" />
<EmbeddedResource Include="ViewXML\vitalSharingOverlay.xml" />
<EmbeddedResource Include="..\Shared\Spells\Spells.csv">
<Link>Shared\Spells\Spells.csv</Link>
</EmbeddedResource>

View file

@ -148,11 +148,38 @@ namespace MosswartMassacre
private EquipmentCantripStateTracker _equipmentCantripStateTracker;
private NearbyObjectsTracker _nearbyObjectsTracker;
private VitalSharingTracker _vitalSharingTracker;
private static PluginCore _instance;
public static VitalSharingTracker VitalSharingTracker => _instance?._vitalSharingTracker;
public static void SetVitalSharingEnabled(bool enabled)
{
try
{
PluginSettings.Instance.VitalSharingEnabled = enabled;
var tracker = _instance?._vitalSharingTracker;
if (tracker == null) return;
if (enabled)
{
tracker.Start();
WriteToChat("[VitalShare] ENABLED");
}
else
{
tracker.Stop();
WriteToChat("[VitalShare] DISABLED");
}
}
catch (Exception ex)
{
WriteToChat($"[VitalShare] Toggle error: {ex.Message}");
}
}
protected override void Startup()
{
try
{
_instance = this;
// Set MyHost - for hot reload scenarios, Host might be null
if (Host != null)
{
@ -1149,7 +1176,7 @@ namespace MosswartMassacre
{
if (args.Length < 2)
{
WriteToChat("Usage: /mm vitalsharing <on|off|status|tag add <name>|tag remove <name>|tags>");
WriteToChat("Usage: /mm vitalsharing <on|off|status|overlay>");
return;
}
@ -1162,82 +1189,26 @@ namespace MosswartMassacre
WriteToChat("[VitalShare] WebSocket must be enabled first. Run /mm ws enable");
return;
}
PluginSettings.Instance.VitalSharingEnabled = true;
_vitalSharingTracker?.Start();
WriteToChat("[VitalShare] ENABLED (will stream to Overlord)");
SetVitalSharingEnabled(true);
}
else if (sub == "off")
{
PluginSettings.Instance.VitalSharingEnabled = false;
_vitalSharingTracker?.Stop();
WriteToChat("[VitalShare] DISABLED");
SetVitalSharingEnabled(false);
}
else if (sub == "status")
{
var enabled = PluginSettings.Instance.VitalSharingEnabled;
var active = _vitalSharingTracker?.IsActive == true;
var tags = PluginSettings.Instance.VitalSharingTags;
var tagStr = (tags == null || tags.Count == 0) ? "(none)" : string.Join(", ", tags);
WriteToChat($"[VitalShare] enabled={enabled} active={active} tags={tagStr}");
var tag = PluginSettings.Instance.CharTag ?? "";
WriteToChat($"[VitalShare] enabled={enabled} active={active} tag='{tag}'");
}
else if (sub == "tags")
else if (sub == "overlay")
{
var tags = PluginSettings.Instance.VitalSharingTags;
if (tags == null || tags.Count == 0)
WriteToChat("[VitalShare] No tags set");
else
WriteToChat("[VitalShare] Tags: " + string.Join(", ", tags));
}
else if (sub == "tag")
{
if (args.Length < 4)
{
WriteToChat("Usage: /mm vitalsharing tag <add|remove> <name>");
return;
}
var action = args[2].ToLowerInvariant();
var name = args[3].Trim();
if (string.IsNullOrEmpty(name))
{
WriteToChat("Tag name cannot be empty");
return;
}
var tags = PluginSettings.Instance.VitalSharingTags ?? new System.Collections.Generic.List<string>();
if (action == "add")
{
if (!tags.Contains(name, StringComparer.OrdinalIgnoreCase))
{
tags.Add(name);
PluginSettings.Instance.VitalSharingTags = tags;
WriteToChat($"[VitalShare] Added tag: {name}");
}
else
{
WriteToChat($"[VitalShare] Tag already set: {name}");
}
}
else if (action == "remove")
{
var match = tags.Find(t => t.Equals(name, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
tags.Remove(match);
PluginSettings.Instance.VitalSharingTags = tags;
WriteToChat($"[VitalShare] Removed tag: {name}");
}
else
{
WriteToChat($"[VitalShare] Tag not found: {name}");
}
}
else
{
WriteToChat("Usage: /mm vitalsharing tag <add|remove> <name>");
}
Views.VitalSharingOverlayView.ToggleWindow();
}
else
{
WriteToChat("Usage: /mm vitalsharing <on|off|status|tag add <name>|tag remove <name>|tags>");
WriteToChat("Usage: /mm vitalsharing <on|off|status|overlay>");
}
}, "Enable/disable vital sharing with other MosswartOverlord clients");

View file

@ -25,7 +25,6 @@ namespace MosswartMassacre
private bool _verboseLogging = false;
private bool _autoUpdateEnabled = true;
private bool _vitalSharingEnabled = false;
private List<string> _vitalSharingTags = new List<string>();
private ChestLooterSettings _chestLooterSettings = new ChestLooterSettings();
public static PluginSettings Instance => _instance
@ -205,16 +204,6 @@ namespace MosswartMassacre
set { _vitalSharingEnabled = value; Save(); }
}
public List<string> VitalSharingTags
{
get
{
if (_vitalSharingTags == null) _vitalSharingTags = new List<string>();
return _vitalSharingTags;
}
set { _vitalSharingTags = value ?? new List<string>(); Save(); }
}
public ChestLooterSettings ChestLooterSettings
{
get

View file

@ -38,10 +38,15 @@
<!-- Auto-update setting -->
<control progid="DecalControls.Checkbox" name="chkAutoUpdateEnabled" left="20" top="85" width="300" height="20" text="Auto install plugin updates" checked="true"/>
<!-- Vital sharing setting -->
<control progid="DecalControls.Checkbox" name="chkVitalSharingEnabled" left="20" top="110" width="200" height="20" text="Vital Sharing (cross-PC)" checked="false"/>
<control progid="DecalControls.PushButton" name="btnVitalSharingOverlay" left="220" top="108" width="100" height="22" text="Open Overlay"/>
<!-- Character tag setting -->
<control progid="DecalControls.StaticText" name="lblCharTag" left="20" top="115" width="100" height="16" text="Character Tag:"/>
<control progid="DecalControls.Edit" name="txtCharTag" left="125" top="113" width="150" height="20" text="default"/>
<control progid="DecalControls.StaticText" name="lblCharTag" left="20" top="140" width="100" height="16" text="Character Tag:"/>
<control progid="DecalControls.Edit" name="txtCharTag" left="125" top="138" width="150" height="20" text="default"/>
<control progid="DecalControls.StaticText" name="lblCharTagHelp" left="20" top="160" width="350" height="16" text="Used for telemetry and vital sharing groups."/>
<!-- VTank profiles path setting -->
<control progid="DecalControls.StaticText" name="lblVTankPath" left="20" top="190" width="100" height="16" text="VTank Profiles:"/>
<control progid="DecalControls.Edit" name="txtVTankPath" left="125" top="188" width="200" height="20" text=""/>

View file

@ -0,0 +1,11 @@
<?xml version="1.0"?>
<view icon="7735" title="Vital Sharing" width="360" height="260">
<control progid="DecalControls.FixedLayout" clipped="">
<control progid="DecalControls.StaticText" name="lblHeader" left="8" top="6" width="340" height="16" text="Vital Sharing Peers" style="FontBold"/>
<control progid="DecalControls.StaticText" name="lblCol0" left="8" top="24" width="160" height="14" text="Character"/>
<control progid="DecalControls.StaticText" name="lblCol1" left="170" top="24" width="55" height="14" text="HP"/>
<control progid="DecalControls.StaticText" name="lblCol2" left="227" top="24" width="55" height="14" text="Stam"/>
<control progid="DecalControls.StaticText" name="lblCol3" left="284" top="24" width="55" height="14" text="Mana"/>
<control progid="DecalControls.List" name="lstPeers" left="6" top="42" width="340" height="200"/>
</control>
</view>

View file

@ -32,6 +32,8 @@ namespace MosswartMassacre.Views
private HudCheckBox chkRareMetaEnabled;
private HudCheckBox chkWebSocketEnabled;
private HudCheckBox chkAutoUpdateEnabled;
private HudCheckBox chkVitalSharingEnabled;
private HudButton btnVitalSharingOverlay;
private HudTextBox txtCharTag;
private HudTextBox txtVTankPath;
#endregion
@ -229,9 +231,11 @@ namespace MosswartMassacre.Views
chkRareMetaEnabled = GetControl<HudCheckBox>("chkRareMetaEnabled");
chkWebSocketEnabled = GetControl<HudCheckBox>("chkWebSocketEnabled");
chkAutoUpdateEnabled = GetControl<HudCheckBox>("chkAutoUpdateEnabled");
chkVitalSharingEnabled = GetControl<HudCheckBox>("chkVitalSharingEnabled");
btnVitalSharingOverlay = GetControl<HudButton>("btnVitalSharingOverlay");
txtCharTag = GetControl<HudTextBox>("txtCharTag");
txtVTankPath = GetControl<HudTextBox>("txtVTankPath");
// Hook up settings events
if (chkRareMetaEnabled != null)
chkRareMetaEnabled.Change += OnRareMetaSettingChanged;
@ -239,6 +243,10 @@ namespace MosswartMassacre.Views
chkWebSocketEnabled.Change += OnWebSocketSettingChanged;
if (chkAutoUpdateEnabled != null)
chkAutoUpdateEnabled.Change += OnAutoUpdateSettingChanged;
if (chkVitalSharingEnabled != null)
chkVitalSharingEnabled.Change += OnVitalSharingSettingChanged;
if (btnVitalSharingOverlay != null)
btnVitalSharingOverlay.Hit += OnVitalSharingOverlayClicked;
if (txtCharTag != null)
txtCharTag.Change += OnCharTagChanged;
if (txtVTankPath != null)
@ -464,6 +472,30 @@ namespace MosswartMassacre.Views
}
}
private void OnVitalSharingSettingChanged(object sender, EventArgs e)
{
try
{
PluginCore.SetVitalSharingEnabled(chkVitalSharingEnabled.Checked);
}
catch (Exception ex)
{
PluginCore.WriteToChat($"Error in vital sharing setting change: {ex.Message}");
}
}
private void OnVitalSharingOverlayClicked(object sender, EventArgs e)
{
try
{
VitalSharingOverlayView.ToggleWindow();
}
catch (Exception ex)
{
PluginCore.WriteToChat($"Error opening vital sharing overlay: {ex.Message}");
}
}
private void OnVTankPathChanged(object sender, EventArgs e)
{
try
@ -617,6 +649,8 @@ namespace MosswartMassacre.Views
{
if (PluginSettings.IsInitialized)
{
if (chkVitalSharingEnabled != null)
chkVitalSharingEnabled.Checked = PluginSettings.Instance.VitalSharingEnabled;
if (chkRareMetaEnabled != null)
chkRareMetaEnabled.Checked = PluginSettings.Instance.RareMetaEnabled;
if (chkWebSocketEnabled != null)

View file

@ -0,0 +1,171 @@
using System;
using System.Drawing;
using VirindiViewService;
using VirindiViewService.Controls;
namespace MosswartMassacre.Views
{
/// <summary>
/// In-game overlay window showing live vitals for every character that has
/// opted-in to vital sharing via MosswartOverlord. Modeled after UB's
/// NetworkUI — one row per peer with HP/Stamina/Mana percentages coloured
/// to match the web sidebar palette.
/// </summary>
internal class VitalSharingOverlayView : VVSBaseView
{
private static VitalSharingOverlayView _instance;
private HudList _peerList;
private System.Timers.Timer _refreshTimer;
// Colors matched to MosswartOverlord player sidebar
// health: #ff4444, stamina: #ffaa00, mana: #4488ff
private static readonly Color COLOR_HP = Color.FromArgb(255, 68, 68);
private static readonly Color COLOR_STA = Color.FromArgb(255, 170, 0);
private static readonly Color COLOR_MANA = Color.FromArgb(68, 136, 255);
private static readonly Color COLOR_NAME = Color.White;
private VitalSharingOverlayView() : base(null)
{
CreateFromXMLResource("MosswartMassacre.ViewXML.vitalSharingOverlay.xml");
Init();
}
public static void ToggleWindow()
{
try
{
if (_instance == null)
{
_instance = new VitalSharingOverlayView();
_instance.Show();
}
else if (_instance.IsVisible)
{
_instance.Hide();
}
else
{
_instance.Show();
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay toggle error: {ex.Message}");
}
}
public static void ForceClose()
{
try
{
_instance?.Dispose();
_instance = null;
}
catch { }
}
private void Init()
{
try
{
_peerList = GetControl<HudList>("lstPeers");
if (_peerList != null)
{
// columns: name, hp%, sta%, mana%
_peerList.AddColumn(typeof(HudStaticText), 160, null);
_peerList.AddColumn(typeof(HudStaticText), 55, null);
_peerList.AddColumn(typeof(HudStaticText), 55, null);
_peerList.AddColumn(typeof(HudStaticText), 55, null);
}
_refreshTimer = new System.Timers.Timer(500);
_refreshTimer.AutoReset = true;
_refreshTimer.Elapsed += (s, e) =>
{
try { RefreshPeers(); }
catch (Exception ex) { PluginCore.WriteToChat($"[VitalShare] Overlay refresh error: {ex.Message}"); }
};
_refreshTimer.Start();
RefreshPeers();
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[VitalShare] Overlay init error: {ex.Message}");
}
}
private void RefreshPeers()
{
if (_peerList == null) return;
var snaps = VitalSharingTracker.GetPeerSnapshots();
_peerList.ClearRows();
if (snaps == null || snaps.Length == 0)
{
var row = _peerList.AddRow();
((HudStaticText)row[0]).Text = "(no peers)";
((HudStaticText)row[0]).TextColor = Color.Gray;
((HudStaticText)row[1]).Text = "";
((HudStaticText)row[2]).Text = "";
((HudStaticText)row[3]).Text = "";
return;
}
Array.Sort(snaps, (a, b) =>
string.Compare(a.CharacterName, b.CharacterName, StringComparison.OrdinalIgnoreCase));
foreach (var p in snaps)
{
var row = _peerList.AddRow();
var name = (HudStaticText)row[0];
name.Text = p.CharacterName;
name.TextColor = COLOR_NAME;
var hp = (HudStaticText)row[1];
hp.Text = FormatPct(p.CurrentHealth, p.MaxHealth);
hp.TextColor = COLOR_HP;
var sta = (HudStaticText)row[2];
sta.Text = FormatPct(p.CurrentStamina, p.MaxStamina);
sta.TextColor = COLOR_STA;
var mana = (HudStaticText)row[3];
mana.Text = FormatPct(p.CurrentMana, p.MaxMana);
mana.TextColor = COLOR_MANA;
}
}
private static string FormatPct(int cur, int max)
{
if (max <= 0) return "--";
int pct = (int)Math.Round(100.0 * cur / max);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return pct + "%";
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
try
{
if (_refreshTimer != null)
{
_refreshTimer.Stop();
_refreshTimer.Dispose();
_refreshTimer = null;
}
}
catch { }
}
base.Dispose(disposing);
if (_instance == this) _instance = null;
}
}
}

View file

@ -61,6 +61,30 @@ namespace MosswartMassacre
public bool IsActive => _active;
// ── Peer snapshot state (used by the in-game overlay) ──
public class PeerSnapshot
{
public string CharacterName;
public int CurrentHealth, MaxHealth;
public int CurrentStamina, MaxStamina;
public int CurrentMana, MaxMana;
public DateTime LastUpdate;
}
private static readonly Dictionary<string, PeerSnapshot> _peers =
new Dictionary<string, PeerSnapshot>(StringComparer.OrdinalIgnoreCase);
private static readonly object _peersLock = new object();
public static PeerSnapshot[] GetPeerSnapshots()
{
lock (_peersLock)
{
var arr = new PeerSnapshot[_peers.Count];
_peers.Values.CopyTo(arr, 0);
return arr;
}
}
public VitalSharingTracker(IPluginLogger logger)
{
_logger = logger;
@ -187,6 +211,20 @@ namespace MosswartMassacre
int maxM = (int?)root["max_mana"] ?? 0;
if (playerId == 0 || maxH == 0) return;
// Cache for overlay view
lock (_peersLock)
{
if (!_peers.TryGetValue(fromChar, out var snap))
{
snap = new PeerSnapshot { CharacterName = fromChar };
_peers[fromChar] = snap;
}
snap.CurrentHealth = curH; snap.MaxHealth = maxH;
snap.CurrentStamina = curS; snap.MaxStamina = maxS;
snap.CurrentMana = curM; snap.MaxMana = maxM;
snap.LastUpdate = DateTime.UtcNow;
}
Enqueue(() =>
{
try
@ -637,8 +675,10 @@ namespace MosswartMassacre
{
try
{
var tags = PluginSettings.Instance.VitalSharingTags;
return tags != null ? tags.ToArray() : new string[0];
// Reuse the existing CharTag setting as a single-value tag
var tag = PluginSettings.Instance.CharTag;
if (string.IsNullOrWhiteSpace(tag)) return new string[0];
return new[] { tag };
}
catch { return new string[0]; }
}