Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/RustPlusBot.Domain/Map/ServerMapSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public sealed class ServerMapSettings
/// <summary>Whether oil rigs are styled by activation state.</summary>
public bool ShowRigs { get; set; } = true;

/// <summary>Whether train-tunnel entrance icons are drawn (separate from monuments).</summary>
public bool ShowTunnels { get; set; } = true;

/// <summary>Which grid convention the rendered map and event grid references use.</summary>
public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame;
}
30 changes: 15 additions & 15 deletions src/RustPlusBot.Features.Map/Assets/MapIcons.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public static class MapIcons
public static Image<Rgba32>? Marker(MarkerKind kind)
{
var key = KeyFor(kind);
return key is null ? null : Load(key);
return key is null ? null : Native(key);
}

/// <summary>Gets the marker icon scaled to fit a square box, or null when the kind has no icon.</summary>
Expand All @@ -38,16 +38,7 @@ public static class MapIcons

/// <summary>Gets the travelling vendor icon.</summary>
/// <returns>The cached vendor icon image, or null.</returns>
public static Image<Rgba32>? Vendor() => Load("vendor");

/// <summary>Gets the player position icon, or null when the asset is missing.</summary>
/// <returns>The player icon, or null.</returns>
public static Image<Rgba32>? Player() => Load("player");

/// <summary>Gets the player icon scaled to fit a square box, or null when the asset is missing.</summary>
/// <param name="size">The box edge length in pixels.</param>
/// <returns>The cached scaled icon, or null.</returns>
public static Image<Rgba32>? Player(int size) => Scaled("player", size);
public static Image<Rgba32>? Vendor() => Native("vendor");

private static string? KeyFor(MarkerKind kind) => kind switch
{
Expand All @@ -58,18 +49,27 @@ public static class MapIcons
_ => null,
};

private static Image<Rgba32>? Load(string key) => Cache.GetOrAdd(key, static k =>
private static Image<Rgba32>? Native(string? key) => key is null
? null
: Cache.GetOrAdd(key, static k => k switch
{
"patrol" => MarkerIconComposer.Heli(),
"ch47" => MarkerIconComposer.Chinook(),
_ => LoadPng(k),
});

private static Image<Rgba32>? LoadPng(string key)
{
var asm = typeof(MapIcons).Assembly;
using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".png");
using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png");
return stream is null ? null : Image.Load<Rgba32>(stream);
});
}

private static Image<Rgba32>? Scaled(string? key, int size) => key is null
? null
: Cache.GetOrAdd($"{key}@{size}", _ =>
{
var native = Load(key);
var native = Native(key);
return native?.Clone(ctx => ctx.Resize(new ResizeOptions
{
Mode = ResizeMode.Max, Size = new Size(size, size),
Expand Down
87 changes: 87 additions & 0 deletions src/RustPlusBot.Features.Map/Assets/MarkerIconComposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

namespace RustPlusBot.Features.Map.Assets;

/// <summary>
/// Builds the patrol-heli and CH47 marker icons by compositing a grey body with rotor blade(s):
/// the heli gets one blade on its hub, the CH47 gets two at its tandem-rotor positions. Blades are
/// drawn into a copy of the body canvas, so the composite keeps the body's dimensions and rotates
/// cleanly about its centre in <see cref="MapRenderer"/>. Offsets/scale are tuned against the
/// reference art (verify visually on a rendered tile).
/// </summary>
internal static class MarkerIconComposer
{
private const string ResourcePrefix = "RustPlusBot.Features.Map.Assets.icons.";

/// <summary>Main-rotor blade edge as a fraction of the body's shorter edge; spans past the fuselage.</summary>
private const float HeliBladeScale = 1.15f;

/// <summary>Each tandem-rotor blade edge as a fraction of the body's shorter edge; smaller than the body.</summary>
private const float ChinookBladeScale = 0.62f;

/// <summary>The single main-rotor hub offset, in body pixels (populated on first access).</summary>
internal static IReadOnlyList<PointF> HeliRotorOffsets { get; } = HeliOffsets();

/// <summary>The two tandem-rotor offsets (front, rear), in body pixels.</summary>
internal static IReadOnlyList<PointF> ChinookRotorOffsets { get; } = ChinookOffsets();

/// <summary>Builds the patrol-heli composite (body + one rotor blade on the hub).</summary>
/// <returns>The composited heli icon; caller owns disposal.</returns>
internal static Image<Rgba32> Heli() =>
Compose("heli", HeliRotorOffsets, HeliBladeScale);

/// <summary>Builds the CH47 composite (body + two rotor blades at the tandem positions).</summary>
/// <returns>The composited chinook icon; caller owns disposal.</returns>
internal static Image<Rgba32> Chinook() =>
Compose("chinook", ChinookRotorOffsets, ChinookBladeScale);

private static IReadOnlyList<PointF> HeliOffsets()
{
using var body = Load("heli");

// Hub sits slightly forward of centre.
return [new PointF(body.Width / 2f, body.Height * 0.44f)];
}

private static IReadOnlyList<PointF> ChinookOffsets()
{
using var body = Load("chinook");
var cx = body.Width / 2f;
return
[
new PointF(cx, body.Height * 0.20f), // front rotor
new PointF(cx, body.Height * 0.80f), // rear rotor
];
}

private static Image<Rgba32> Compose(string bodyKey, IReadOnlyList<PointF> offsets, float bladeScale)
{
var canvas = Load(bodyKey); // returned to caller (cached by MapIcons); not disposed here
using var blade = Load("blade");
var edge = (int)(Math.Min(canvas.Width, canvas.Height) * bladeScale);
using var scaledBlade = blade.Clone(c => c.Resize(edge, edge));

canvas.Mutate(ctx =>
{
foreach (var offset in offsets)
{
var topLeft = new Point(
(int)(offset.X - (scaledBlade.Width / 2f)),
(int)(offset.Y - (scaledBlade.Height / 2f)));
ctx.DrawImage(scaledBlade, topLeft, 1f);
}
});

return canvas;
}

private static Image<Rgba32> Load(string key)
{
var asm = typeof(MarkerIconComposer).Assembly;
using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png")
?? throw new InvalidOperationException($"Embedded marker asset '{key}.png' not found.");
return Image.Load<Rgba32>(stream);
}
}
14 changes: 14 additions & 0 deletions src/RustPlusBot.Features.Map/Assets/TunnelTokens.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace RustPlusBot.Features.Map.Assets;

/// <summary>
/// The Rust+ monument tokens that belong to the separately-toggleable Tunnels layer rather than the
/// general Monuments layer. Trainyard and Military Tunnels stay ordinary monuments.
/// </summary>
public static class TunnelTokens
{
/// <summary>The train-tunnel entrance and link tokens.</summary>
public static IReadOnlySet<string> All { get; } = new HashSet<string>(StringComparer.Ordinal)
{
"train_tunnel_display_name", "train_tunnel_link_display_name",
};
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/RustPlusBot.Features.Map/Assets/icons/ch47.png
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed src/RustPlusBot.Features.Map/Assets/icons/patrol.png
Binary file not shown.
Binary file not shown.
Binary file modified src/RustPlusBot.Features.Map/Assets/icons/vendor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
95 changes: 72 additions & 23 deletions src/RustPlusBot.Features.Map/Composing/MapComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Events;
using RustPlusBot.Features.Events.State;
using RustPlusBot.Features.Map.Assets;
using RustPlusBot.Features.Map.Rendering;
using RustPlusBot.Persistence.Map;
using SixLabors.ImageSharp;
Expand Down Expand Up @@ -31,8 +32,8 @@ public sealed class MapComposer(
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>PNG bytes, or null.</returns>
public async Task<byte[]?> ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
/// <returns>The rendered composition, or null when no base map is available yet.</returns>
public async Task<MapComposition?> ComposeAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken)
{
// The settings store is scoped (EF context); this composer is a singleton, so we open a scope per
// call to resolve it (mirroring MapHostedService.OnConnectionStatusAsync) — avoids a captive dependency.
Expand All @@ -45,12 +46,12 @@ public sealed class MapComposer(
}

var layers = new MapLayerSet(settings.Grid, settings.Markers, settings.Monuments,
settings.Vendor, settings.Players, settings.Rigs);
settings.Vendor, settings.Players, settings.Rigs, settings.Tunnels);
return await ComposeWithLayersAsync(guildId, serverId, layers, settings.GridStyle, cancellationToken)
.ConfigureAwait(false);
}

private async Task<byte[]?> ComposeWithLayersAsync(
private async Task<MapComposition?> ComposeWithLayersAsync(
ulong guildId,
Guid serverId,
MapLayerSet layers,
Expand All @@ -69,31 +70,35 @@ public sealed class MapComposer(
if (dims is null || dims.WorldSize == 0)
{
// Dimensions unavailable: render the base tile only (every overlay needs world→pixel).
return renderer.Render(baseImage.Bytes, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize),
markers: [], monuments: [], players: [], rigs: [],
new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false,
Rigs: false));
return new MapComposition(
renderer.Render(baseImage.Bytes, new MapProjection(0, 1, 1, 0, MapRenderer.OutputSize),
markers: [], monuments: [], players: [], rigs: [],
new MapLayerSet(Grid: false, Markers: false, Monuments: false, Vendor: false, Players: false,
Rigs: false, Tunnels: false)),
Legend: null);
}

var projection = new MapProjection(dims.WorldSize, baseImage.PixelWidth, baseImage.PixelHeight,
baseImage.OceanMarginPx, MapRenderer.OutputSize);

var markers = GatherMarkers(guildId, serverId, projection, layers);

// Monuments feed both the monuments layer and the rig-styling layer; fetch them once when either is on.
// Monuments feed the monuments, rig-styling, and tunnels layers; fetch them once when any is on.
IReadOnlyList<MonumentSnapshot> serverMonuments = [];
if (layers.Monuments || layers.Rigs)
if (layers.Monuments || layers.Rigs || layers.Tunnels)
{
serverMonuments = await query.GetMonumentsAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
}

var monuments = GatherMonuments(serverMonuments, projection, layers);
var players = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken)
var tunnels = GatherTunnels(serverMonuments, projection, layers);
var (players, legend) = await GatherPlayersAsync(guildId, serverId, projection, layers, cancellationToken)
.ConfigureAwait(false);
var rigPlacements = GatherRigs(guildId, serverId, serverMonuments, projection, layers);

return renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers,
gridStyle);
var png = renderer.Render(baseImage.Bytes, projection, markers, monuments, players, rigPlacements, layers,
gridStyle, tunnels);
return new MapComposition(png, legend);
}

private List<MarkerPlacement> GatherMarkers(
Expand Down Expand Up @@ -151,6 +156,11 @@ private static List<MonumentPlacement> GatherMonuments(
{
foreach (var mon in serverMonuments)
{
if (TunnelTokens.All.Contains(mon.Token))
{
continue; // routed to the Tunnels layer instead
}

var (px, py) = projection.ToPixel(mon.X, mon.Y);
monuments.Add(new MonumentPlacement(mon.Token, px, py));
}
Expand All @@ -159,25 +169,64 @@ private static List<MonumentPlacement> GatherMonuments(
return monuments;
}

private async Task<List<PlayerPlacement>> GatherPlayersAsync(
private static List<MonumentPlacement> GatherTunnels(
IReadOnlyList<MonumentSnapshot> serverMonuments,
MapProjection projection,
MapLayerSet layers)
{
var tunnels = new List<MonumentPlacement>();
if (layers.Tunnels)
{
foreach (var mon in serverMonuments.Where(m => TunnelTokens.All.Contains(m.Token)))
{
var (px, py) = projection.ToPixel(mon.X, mon.Y);
tunnels.Add(new MonumentPlacement(mon.Token, px, py));
}
}

return tunnels;
}

private async Task<(List<PlayerPlacement> Players, MapLegend? Legend)> GatherPlayersAsync(
ulong guildId,
Guid serverId,
MapProjection projection,
MapLayerSet layers,
CancellationToken cancellationToken)
{
var players = new List<PlayerPlacement>();
if (layers.Players)
if (!layers.Players)
{
var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
foreach (var member in team?.Members ?? [])
{
var (px, py) = projection.ToPixel(member.X, member.Y);
players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline));
}
return ([], null);
}

var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
// Stable color per player: order by SteamId, index into the palette. SteamId never changes,
// so a player keeps their color across refreshes regardless of online/offline ordering. The
// legend is built from the same index so a cross color and its legend row never drift.
var ordered = (team?.Members ?? []).OrderBy(m => m.SteamId).ToList();
if (ordered.Count == 0)
{
return ([], null);
}

return players;
var players = new List<PlayerPlacement>(ordered.Count);
var legend = new List<MapLegendEntry>(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
{
var member = ordered[i];
var color = PlayerPalette.For(i);
var (px, py) = projection.ToPixel(member.X, member.Y);
players.Add(new PlayerPlacement(member.Name, px, py, member.IsAlive, member.IsOnline, color.Rgba));
legend.Add(new MapLegendEntry(color.Emoji, member.Name, StatusText(member)));
}

return (players, new MapLegend(legend));
}

private static string StatusText(TeamMemberSnapshot member)
{
var presence = member.IsOnline ? "online" : "offline";
return member.IsAlive ? presence : presence + ", dead";
}

private List<RigPlacement> GatherRigs(
Expand Down
16 changes: 16 additions & 0 deletions src/RustPlusBot.Features.Map/Composing/MapLegend.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace RustPlusBot.Features.Map.Composing;

/// <summary>One legend row: a colored square, the player's name, and their status.</summary>
/// <param name="Emoji">The colored-square emoji matching the player's on-map cross color.</param>
/// <param name="Name">The player's in-game display name.</param>
/// <param name="Status">Human-readable status, e.g. "online" or "offline, dead".</param>
public sealed record MapLegendEntry(string Emoji, string Name, string Status);

/// <summary>The player legend for a rendered map, one entry per teammate.</summary>
/// <param name="Entries">The legend rows, ordered as the crosses were assigned (by SteamId).</param>
public sealed record MapLegend(IReadOnlyList<MapLegendEntry> Entries);

/// <summary>A rendered map: the PNG plus its optional player legend.</summary>
/// <param name="Png">The rendered PNG bytes.</param>
/// <param name="Legend">The player legend, or null when the Players layer is off or the team is empty.</param>
public sealed record MapComposition(byte[] Png, MapLegend? Legend);
6 changes: 3 additions & 3 deletions src/RustPlusBot.Features.Map/Hosting/MapHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,13 @@ private async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken
return;
}

var png = await _composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (png is null)
var composition = await _composer.ComposeAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (composition is null)
{
return;
}

await _poster.PostAsync(id, png, cancellationToken).ConfigureAwait(false);
await _poster.PostAsync(id, composition.Png, composition.Legend, cancellationToken).ConfigureAwait(false);
}

private async Task ConsumeConnectionStatusEventsAsync(CancellationToken cancellationToken)
Expand Down
Loading
Loading