Radar: nearby_objects every second and dungeon_map per dungeon landblock
start_radar/stop_radar from the backend drive it, as before. nearby_objects keeps the original's shape: map coordinates on the surface, landblock-local raw positions with zero map coordinates in a dungeon, ids as signed ints, the same eight object classes, no wielded items or self. dungeon_map is built the original's way from the landblock's cells (x a multiple of 10, whole metres, 6-metre floors, rotation = the orientation's stored W) and sent once per landblock per session; the cells come through a small reader that the plugin API's dungeon-cell data will fill (until then the map is not sent). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0efef6170c
commit
ec22348ebc
6 changed files with 425 additions and 0 deletions
|
|
@ -36,5 +36,16 @@ internal static class FeatureCatalog
|
|||
session.AddConnectionListener(sharing);
|
||||
session.AddShareHandler(sharing);
|
||||
features.Add(new Sharing.VitalSharingOverlay(context, sharing));
|
||||
|
||||
// The radar the backend turns on and off.
|
||||
var radar = new Radar.RadarStream(context, cells: null);
|
||||
features.Add(radar);
|
||||
session.RadarToggle = on =>
|
||||
{
|
||||
if (on)
|
||||
radar.Start();
|
||||
else
|
||||
radar.Stop();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
Normal file
83
src/OpenAC.MosswartMassacre/Radar/DungeonMapBuilder.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Radar;
|
||||
|
||||
/// <summary>
|
||||
/// One indoor cell of a landblock as the game data stores it: the low half of
|
||||
/// its environment id, its origin in the landblock (metres, as stored), and
|
||||
/// the first stored component of its orientation, which the original sent as
|
||||
/// the cell's <c>rotation</c>.
|
||||
/// </summary>
|
||||
internal readonly record struct DungeonCellRecord(ushort EnvironmentId, float X, float Y, float Z, float RotationW);
|
||||
|
||||
/// <summary>Reads a landblock's indoor cells from the game data.</summary>
|
||||
internal interface IDungeonCellSource
|
||||
{
|
||||
/// <summary>
|
||||
/// The landblock's indoor cells in cell order, or null when the host cannot
|
||||
/// read them (the frame is then not sent).
|
||||
/// </summary>
|
||||
IReadOnlyList<DungeonCellRecord>? ReadCells(uint landblock);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>dungeon_map</c> frame exactly as the original did from the
|
||||
/// same cells: cells whose x is not a multiple of 10 dropped, coordinates
|
||||
/// rounded to whole metres, grouped into 6-metre floors.
|
||||
/// </summary>
|
||||
internal static class DungeonMapBuilder
|
||||
{
|
||||
public static object? Build(uint landblock, string characterName, IReadOnlyList<DungeonCellRecord> records)
|
||||
{
|
||||
var cells = new List<CellData>();
|
||||
int minX = int.MaxValue, maxX = int.MinValue;
|
||||
int minY = int.MaxValue, maxY = int.MinValue;
|
||||
foreach (DungeonCellRecord record in records)
|
||||
{
|
||||
int x = (int)Math.Round(record.X);
|
||||
int y = (int)Math.Round(record.Y);
|
||||
int z = (int)Math.Round(record.Z);
|
||||
if (x % 10 != 0)
|
||||
continue;
|
||||
cells.Add(new CellData
|
||||
{
|
||||
env_id = record.EnvironmentId,
|
||||
x = x,
|
||||
y = y,
|
||||
z = z,
|
||||
rotation = record.RotationW,
|
||||
});
|
||||
minX = Math.Min(minX, x);
|
||||
maxX = Math.Max(maxX, x);
|
||||
minY = Math.Min(minY, y);
|
||||
maxY = Math.Max(maxY, y);
|
||||
}
|
||||
if (cells.Count == 0)
|
||||
return null;
|
||||
|
||||
var zLevels = cells
|
||||
.GroupBy(cell => (int)(Math.Floor((cell.z + 3.0) / 6.0) * 6.0))
|
||||
.Select(group => new { z = group.Key, cells = group.ToList() })
|
||||
.OrderBy(group => group.z)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
type = "dungeon_map",
|
||||
character_name = characterName,
|
||||
landblock = string.Create(CultureInfo.InvariantCulture, $"0x{landblock:X8}"),
|
||||
bounds = new { min_x = minX, max_x = maxX, min_y = minY, max_y = maxY },
|
||||
z_levels = zLevels,
|
||||
};
|
||||
}
|
||||
|
||||
// Public fields, in this order, are the wire shape the original sent.
|
||||
#pragma warning disable CA1051, IDE1006, SA1307
|
||||
private sealed class CellData
|
||||
{
|
||||
public ushort env_id;
|
||||
public int x, y, z;
|
||||
public float rotation;
|
||||
}
|
||||
#pragma warning restore CA1051, IDE1006, SA1307
|
||||
}
|
||||
200
src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
Normal file
200
src/OpenAC.MosswartMassacre/Radar/RadarStream.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
using OpenAC.MosswartMassacre.Infrastructure;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Radar;
|
||||
|
||||
/// <summary>
|
||||
/// The radar the backend turns on with <c>start_radar</c>: every second a
|
||||
/// <c>nearby_objects</c> frame with the player and the monsters, players,
|
||||
/// NPCs, vendors, portals, corpses, containers and doors around, and on
|
||||
/// entering a dungeon landblock its <c>dungeon_map</c>, once per landblock
|
||||
/// per session.
|
||||
/// </summary>
|
||||
internal sealed class RadarStream : IMmFeature
|
||||
{
|
||||
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly MmContext _context;
|
||||
private readonly IDungeonCellSource? _cells;
|
||||
private readonly HashSet<uint> _mapsSent = [];
|
||||
private IDisposable? _timer;
|
||||
private uint _lastLandblock;
|
||||
private bool _inDungeon;
|
||||
|
||||
public RadarStream(MmContext context, IDungeonCellSource? cells)
|
||||
{
|
||||
_context = context;
|
||||
_cells = cells;
|
||||
}
|
||||
|
||||
public bool IsActive => _timer is not null;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsActive)
|
||||
return;
|
||||
_lastLandblock = 0;
|
||||
_inDungeon = false;
|
||||
_timer = _context.Scheduler.Every(Interval, OnTick);
|
||||
_context.Verbose("[Radar] Started nearby objects tracker");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!IsActive)
|
||||
return;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_context.Verbose("[Radar] Stopped nearby objects tracker");
|
||||
}
|
||||
|
||||
public void OnLogin()
|
||||
{
|
||||
}
|
||||
|
||||
public void OnLogoff() => Stop();
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
internal void OnTick()
|
||||
{
|
||||
try
|
||||
{
|
||||
CheckDungeonTransition();
|
||||
if (BuildNearbyObjects() is { } payload)
|
||||
_context.Send(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Verbose($"[Radar] Tick error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckDungeonTransition()
|
||||
{
|
||||
uint landcell = _context.Automation.Navigation.Snapshot.Position.CellId;
|
||||
uint landblock = landcell & 0xFFFF0000;
|
||||
if (landblock == _lastLandblock)
|
||||
return;
|
||||
_lastLandblock = landblock;
|
||||
_inDungeon = IsDungeon(landcell);
|
||||
if (!_inDungeon || _cells is null || !_mapsSent.Add(landblock))
|
||||
return;
|
||||
|
||||
IReadOnlyList<DungeonCellRecord>? records = _cells.ReadCells(landblock);
|
||||
if (records is null)
|
||||
return;
|
||||
if (DungeonMapBuilder.Build(landblock, _context.CharacterName, records) is { } map)
|
||||
{
|
||||
string json = Wire.Serialize(map);
|
||||
_context.Verbose($"[Radar] Sending dungeon_map ({json.Length} bytes) for 0x{landblock:X8}");
|
||||
_context.SendRaw(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The original's test: an indoor cell (index 0x100 and up) that does not see outside.</summary>
|
||||
private bool IsDungeon(uint landcell) =>
|
||||
(landcell & 0x0000FFFF) >= 0x0100 && _context.Automation.DungeonMap.IsSealedDungeon(landcell);
|
||||
|
||||
internal object? BuildNearbyObjects()
|
||||
{
|
||||
if (!_context.IsLoggedIn)
|
||||
return null;
|
||||
uint playerId = _context.Automation.Character.ObjectId;
|
||||
PluginNavigationPosition here = _context.Automation.Navigation.Snapshot.Position;
|
||||
MmCoordinates player = MmCoordinates.From(here);
|
||||
Local playerRaw = Local.Of(here);
|
||||
uint landblock = here.CellId & 0xFFFF0000;
|
||||
|
||||
var objects = new List<object>();
|
||||
foreach (PluginWorldObject wo in _context.Automation.Objects.CaptureObjects())
|
||||
{
|
||||
if (wo.ObjectId == playerId || !wo.IsLandscape || !wo.HasPosition || wo.WielderObjectId != 0)
|
||||
continue;
|
||||
string? objectClass = Classify(wo.ObjectClass);
|
||||
if (objectClass is null)
|
||||
continue;
|
||||
|
||||
if (_inDungeon)
|
||||
{
|
||||
Local raw = Local.Of(wo.Position);
|
||||
if (raw.X == 0 && raw.Y == 0 && raw.Z == 0)
|
||||
continue;
|
||||
objects.Add(new
|
||||
{
|
||||
id = unchecked((int)wo.ObjectId),
|
||||
name = wo.Name,
|
||||
object_class = objectClass,
|
||||
ew = 0.0,
|
||||
ns = 0.0,
|
||||
z = 0.0,
|
||||
raw_x = Math.Round(raw.X, 2),
|
||||
raw_y = Math.Round(raw.Y, 2),
|
||||
raw_z = Math.Round(raw.Z, 2),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
double ew = wo.Position.EastWest;
|
||||
double ns = wo.Position.NorthSouth;
|
||||
if (ew == 0 && ns == 0)
|
||||
continue;
|
||||
objects.Add(new
|
||||
{
|
||||
id = unchecked((int)wo.ObjectId),
|
||||
name = wo.Name,
|
||||
object_class = objectClass,
|
||||
ew = Math.Round(ew, 7),
|
||||
ns = Math.Round(ns, 7),
|
||||
z = Math.Round(Local.Of(wo.Position).Z, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
type = "nearby_objects",
|
||||
character_name = _context.CharacterName,
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
player_ew = Math.Round(player.EW, 7),
|
||||
player_ns = Math.Round(player.NS, 7),
|
||||
player_z = Math.Round(player.Z, 2),
|
||||
player_heading = Math.Round((double)here.HeadingDegrees, 1),
|
||||
is_dungeon = _inDungeon,
|
||||
landblock = string.Create(CultureInfo.InvariantCulture, $"0x{landblock:X8}"),
|
||||
player_x = Math.Round(playerRaw.X, 2),
|
||||
player_y = Math.Round(playerRaw.Y, 2),
|
||||
player_raw_z = Math.Round(playerRaw.Z, 2),
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Classify(PluginObjectClass objectClass) => objectClass switch
|
||||
{
|
||||
PluginObjectClass.Monster => "Monster",
|
||||
PluginObjectClass.Player => "Player",
|
||||
PluginObjectClass.Npc => "NPC",
|
||||
PluginObjectClass.Vendor => "Vendor",
|
||||
PluginObjectClass.Portal => "Portal",
|
||||
PluginObjectClass.Corpse => "Corpse",
|
||||
PluginObjectClass.Container => "Container",
|
||||
PluginObjectClass.Door => "Door",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>A position in its landblock's own frame, as the floats the original read from the physics object.</summary>
|
||||
private readonly record struct Local(double X, double Y, double Z)
|
||||
{
|
||||
public static Local Of(in PluginNavigationPosition position)
|
||||
{
|
||||
uint blockX = (position.CellId >> 24) & 0xFFu;
|
||||
uint blockY = (position.CellId >> 16) & 0xFFu;
|
||||
float x = (float)((position.EastWest * 240d) + 84d - (((double)blockX - 127d) * 192d));
|
||||
float y = (float)((position.NorthSouth * 240d) + 84d - (((double)blockY - 127d) * 192d));
|
||||
float z = (float)(position.Elevation * 240d);
|
||||
return new Local(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue