feat: add on-demand nearby objects radar streaming

New NearbyObjectsTracker polls WorldFilter.GetLandscape() every 1s
when activated by start_radar/stop_radar commands from the browser.
Streams all visible objects (monsters, players, NPCs, portals, etc.)
with coordinates to the backend for real-time radar display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-07 23:10:42 +02:00
parent 98c43c4c61
commit 5f20d395a6
5 changed files with 201 additions and 0 deletions

View file

@ -347,6 +347,7 @@
<Compile Include="Views\VVSBaseView.cs" />
<Compile Include="Views\VVSTabbedMainView.cs" />
<Compile Include="CharacterStats.cs" />
<Compile Include="NearbyObjectsTracker.cs" />
<Compile Include="WebSocket.cs" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
using Newtonsoft.Json;
namespace MosswartMassacre
{
/// <summary>
/// On-demand tracker that polls nearby world objects and streams them
/// to the backend via WebSocket. Activated/deactivated by start_radar
/// and stop_radar commands from the browser.
///
/// Uses System.Windows.Forms.Timer so the tick fires on the main UI
/// thread — DECAL COM objects are apartment-threaded (STA) and must
/// only be accessed from the game's main thread.
/// </summary>
public class NearbyObjectsTracker : IDisposable
{
private const int PollIntervalMs = 1000;
private readonly IPluginLogger _logger;
private System.Windows.Forms.Timer _timer;
private bool _active;
private bool _disposed;
public bool IsActive => _active;
public NearbyObjectsTracker(IPluginLogger logger)
{
_logger = logger;
}
public void Start()
{
if (_active) return;
_active = true;
_timer = new System.Windows.Forms.Timer();
_timer.Interval = PollIntervalMs;
_timer.Tick += OnTick;
_timer.Start();
_logger?.Log("[Radar] Started nearby objects tracker");
}
public void Stop()
{
if (!_active) return;
_active = false;
if (_timer != null)
{
_timer.Stop();
_timer.Tick -= OnTick;
_timer.Dispose();
_timer = null;
}
_logger?.Log("[Radar] Stopped nearby objects tracker");
}
private async void OnTick(object sender, EventArgs e)
{
try
{
var payload = BuildNearbyObjectsPayload();
if (payload == null) return;
var json = JsonConvert.SerializeObject(payload);
await WebSocket.SendNearbyObjectsAsync(json);
}
catch (Exception ex)
{
_logger?.Log($"[Radar] Tick error: {ex.Message}");
}
}
private object BuildNearbyObjectsPayload()
{
try
{
var playerCoords = Coordinates.Me;
double playerHeading = CoreManager.Current.Actions.Heading;
string characterName = CoreManager.Current.CharacterFilter.Name;
int playerId = CoreManager.Current.CharacterFilter.Id;
var objects = new List<object>();
using (var landscape = CoreManager.Current.WorldFilter.GetLandscape())
{
foreach (WorldObject wo in landscape)
{
try
{
if (wo.Id == playerId) continue;
if (!CoreManager.Current.Actions.IsValidObject(wo.Id)) continue;
// Skip wielded items (equipped weapons/shields on other players)
int wielder = 0;
try { wielder = wo.Values(LongValueKey.Wielder, 0); } catch { }
if (wielder != 0) continue;
string objectClass = ClassifyObject(wo.ObjectClass);
if (objectClass == null) continue;
// Get coordinates
var coords = Utils.GetWorldObjectCoordinates(wo);
if (coords == null || (coords.EW == 0 && coords.NS == 0)) continue;
objects.Add(new
{
id = wo.Id,
name = wo.Name,
object_class = objectClass,
ew = Math.Round(coords.EW, 7),
ns = Math.Round(coords.NS, 7),
z = Math.Round(coords.Z, 2)
});
}
catch
{
// Skip individual objects that fail
}
}
}
return new
{
type = "nearby_objects",
character_name = characterName,
timestamp = DateTime.UtcNow.ToString("o"),
player_ew = Math.Round(playerCoords.EW, 7),
player_ns = Math.Round(playerCoords.NS, 7),
player_z = Math.Round(playerCoords.Z, 2),
player_heading = Math.Round(playerHeading, 1),
objects
};
}
catch (Exception ex)
{
_logger?.Log($"[Radar] Build payload error: {ex.Message}");
return null;
}
}
/// <summary>
/// Maps DECAL ObjectClass to a string label for the radar.
/// Returns null for object classes we don't want to track.
/// </summary>
private static string ClassifyObject(ObjectClass oc)
{
switch (oc)
{
case ObjectClass.Monster: return "Monster";
case ObjectClass.Player: return "Player";
case ObjectClass.Npc: return "NPC";
case ObjectClass.Vendor: return "Vendor";
case ObjectClass.Portal: return "Portal";
case ObjectClass.Corpse: return "Corpse";
case ObjectClass.Container: return "Container";
case ObjectClass.Door: return "Door";
default: return null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Stop();
}
}
}

View file

@ -146,6 +146,7 @@ namespace MosswartMassacre
private CommandRouter _commandRouter;
private LiveInventoryTracker _liveInventoryTracker;
private EquipmentCantripStateTracker _equipmentCantripStateTracker;
private NearbyObjectsTracker _nearbyObjectsTracker;
protected override void Startup()
{
@ -305,6 +306,9 @@ namespace MosswartMassacre
// Initialize navigation visualization system
navVisualization = new NavVisualization();
// Initialize nearby objects tracker (radar)
_nearbyObjectsTracker = new NearbyObjectsTracker(this);
// Initialize command router
_commandRouter = new CommandRouter();
RegisterCommands();
@ -410,6 +414,10 @@ namespace MosswartMassacre
questManager = null;
}
// Stop radar tracker
_nearbyObjectsTracker?.Dispose();
_nearbyObjectsTracker = null;
// Clean up the view
ViewManager.ViewDestroy();
//Disable vtank interface
@ -902,6 +910,18 @@ namespace MosswartMassacre
{
try
{
// Handle radar commands directly (don't send to chat box)
if (command == "start_radar")
{
_nearbyObjectsTracker?.Start();
return;
}
if (command == "stop_radar")
{
_nearbyObjectsTracker?.Stop();
return;
}
// Execute ALL WebSocket commands on main thread - fast and reliable
DispatchChatToBoxWithPluginIntercept(command);
}

View file

@ -349,6 +349,11 @@ namespace MosswartMassacre
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendNearbyObjectsAsync(string json)
{
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendQuestDataAsync(string questName, string countdown)
{
var envelope = new