Skip to content

Commit b7f046b

Browse files
HandyS11claude
andauthored
Map visual overhaul: event-icon rotors, subtle trails, tunnels layer, player crosses + legend (#47)
* feat(map): composite event icons with correctly-placed rotors Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): soften motion trails to a faint dashed hint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): persist a Tunnels layer toggle (defaults on) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): render train tunnels as a separate toggleable layer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(map): isolate the tunnel-routing composer test (fix confounded layers) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): add the Tunnels layer toggle button + localization Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): draw players as stable palette-colored crosses, drop name labels Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): build a player legend alongside the rendered PNG Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(map): attach the player legend as an embed on the map image Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent def4d25 commit b7f046b

42 files changed

Lines changed: 1421 additions & 125 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/RustPlusBot.Domain/Map/ServerMapSettings.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public sealed class ServerMapSettings
2929
/// <summary>Whether oil rigs are styled by activation state.</summary>
3030
public bool ShowRigs { get; set; } = true;
3131

32+
/// <summary>Whether train-tunnel entrance icons are drawn (separate from monuments).</summary>
33+
public bool ShowTunnels { get; set; } = true;
34+
3235
/// <summary>Which grid convention the rendered map and event grid references use.</summary>
3336
public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame;
3437
}

src/RustPlusBot.Features.Map/Assets/MapIcons.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static class MapIcons
2727
public static Image<Rgba32>? Marker(MarkerKind kind)
2828
{
2929
var key = KeyFor(kind);
30-
return key is null ? null : Load(key);
30+
return key is null ? null : Native(key);
3131
}
3232

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

3939
/// <summary>Gets the travelling vendor icon.</summary>
4040
/// <returns>The cached vendor icon image, or null.</returns>
41-
public static Image<Rgba32>? Vendor() => Load("vendor");
42-
43-
/// <summary>Gets the player position icon, or null when the asset is missing.</summary>
44-
/// <returns>The player icon, or null.</returns>
45-
public static Image<Rgba32>? Player() => Load("player");
46-
47-
/// <summary>Gets the player icon scaled to fit a square box, or null when the asset is missing.</summary>
48-
/// <param name="size">The box edge length in pixels.</param>
49-
/// <returns>The cached scaled icon, or null.</returns>
50-
public static Image<Rgba32>? Player(int size) => Scaled("player", size);
41+
public static Image<Rgba32>? Vendor() => Native("vendor");
5142

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

61-
private static Image<Rgba32>? Load(string key) => Cache.GetOrAdd(key, static k =>
52+
private static Image<Rgba32>? Native(string? key) => key is null
53+
? null
54+
: Cache.GetOrAdd(key, static k => k switch
55+
{
56+
"patrol" => MarkerIconComposer.Heli(),
57+
"ch47" => MarkerIconComposer.Chinook(),
58+
_ => LoadPng(k),
59+
});
60+
61+
private static Image<Rgba32>? LoadPng(string key)
6262
{
6363
var asm = typeof(MapIcons).Assembly;
64-
using var stream = asm.GetManifestResourceStream(ResourcePrefix + k + ".png");
64+
using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png");
6565
return stream is null ? null : Image.Load<Rgba32>(stream);
66-
});
66+
}
6767

6868
private static Image<Rgba32>? Scaled(string? key, int size) => key is null
6969
? null
7070
: Cache.GetOrAdd($"{key}@{size}", _ =>
7171
{
72-
var native = Load(key);
72+
var native = Native(key);
7373
return native?.Clone(ctx => ctx.Resize(new ResizeOptions
7474
{
7575
Mode = ResizeMode.Max, Size = new Size(size, size),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using SixLabors.ImageSharp;
2+
using SixLabors.ImageSharp.PixelFormats;
3+
using SixLabors.ImageSharp.Processing;
4+
5+
namespace RustPlusBot.Features.Map.Assets;
6+
7+
/// <summary>
8+
/// Builds the patrol-heli and CH47 marker icons by compositing a grey body with rotor blade(s):
9+
/// the heli gets one blade on its hub, the CH47 gets two at its tandem-rotor positions. Blades are
10+
/// drawn into a copy of the body canvas, so the composite keeps the body's dimensions and rotates
11+
/// cleanly about its centre in <see cref="MapRenderer"/>. Offsets/scale are tuned against the
12+
/// reference art (verify visually on a rendered tile).
13+
/// </summary>
14+
internal static class MarkerIconComposer
15+
{
16+
private const string ResourcePrefix = "RustPlusBot.Features.Map.Assets.icons.";
17+
18+
/// <summary>Main-rotor blade edge as a fraction of the body's shorter edge; spans past the fuselage.</summary>
19+
private const float HeliBladeScale = 1.15f;
20+
21+
/// <summary>Each tandem-rotor blade edge as a fraction of the body's shorter edge; smaller than the body.</summary>
22+
private const float ChinookBladeScale = 0.62f;
23+
24+
/// <summary>The single main-rotor hub offset, in body pixels (populated on first access).</summary>
25+
internal static IReadOnlyList<PointF> HeliRotorOffsets { get; } = HeliOffsets();
26+
27+
/// <summary>The two tandem-rotor offsets (front, rear), in body pixels.</summary>
28+
internal static IReadOnlyList<PointF> ChinookRotorOffsets { get; } = ChinookOffsets();
29+
30+
/// <summary>Builds the patrol-heli composite (body + one rotor blade on the hub).</summary>
31+
/// <returns>The composited heli icon; caller owns disposal.</returns>
32+
internal static Image<Rgba32> Heli() =>
33+
Compose("heli", HeliRotorOffsets, HeliBladeScale);
34+
35+
/// <summary>Builds the CH47 composite (body + two rotor blades at the tandem positions).</summary>
36+
/// <returns>The composited chinook icon; caller owns disposal.</returns>
37+
internal static Image<Rgba32> Chinook() =>
38+
Compose("chinook", ChinookRotorOffsets, ChinookBladeScale);
39+
40+
private static IReadOnlyList<PointF> HeliOffsets()
41+
{
42+
using var body = Load("heli");
43+
44+
// Hub sits slightly forward of centre.
45+
return [new PointF(body.Width / 2f, body.Height * 0.44f)];
46+
}
47+
48+
private static IReadOnlyList<PointF> ChinookOffsets()
49+
{
50+
using var body = Load("chinook");
51+
var cx = body.Width / 2f;
52+
return
53+
[
54+
new PointF(cx, body.Height * 0.20f), // front rotor
55+
new PointF(cx, body.Height * 0.80f), // rear rotor
56+
];
57+
}
58+
59+
private static Image<Rgba32> Compose(string bodyKey, IReadOnlyList<PointF> offsets, float bladeScale)
60+
{
61+
var canvas = Load(bodyKey); // returned to caller (cached by MapIcons); not disposed here
62+
using var blade = Load("blade");
63+
var edge = (int)(Math.Min(canvas.Width, canvas.Height) * bladeScale);
64+
using var scaledBlade = blade.Clone(c => c.Resize(edge, edge));
65+
66+
canvas.Mutate(ctx =>
67+
{
68+
foreach (var offset in offsets)
69+
{
70+
var topLeft = new Point(
71+
(int)(offset.X - (scaledBlade.Width / 2f)),
72+
(int)(offset.Y - (scaledBlade.Height / 2f)));
73+
ctx.DrawImage(scaledBlade, topLeft, 1f);
74+
}
75+
});
76+
77+
return canvas;
78+
}
79+
80+
private static Image<Rgba32> Load(string key)
81+
{
82+
var asm = typeof(MarkerIconComposer).Assembly;
83+
using var stream = asm.GetManifestResourceStream(ResourcePrefix + key + ".png")
84+
?? throw new InvalidOperationException($"Embedded marker asset '{key}.png' not found.");
85+
return Image.Load<Rgba32>(stream);
86+
}
87+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace RustPlusBot.Features.Map.Assets;
2+
3+
/// <summary>
4+
/// The Rust+ monument tokens that belong to the separately-toggleable Tunnels layer rather than the
5+
/// general Monuments layer. Trainyard and Military Tunnels stay ordinary monuments.
6+
/// </summary>
7+
public static class TunnelTokens
8+
{
9+
/// <summary>The train-tunnel entrance and link tokens.</summary>
10+
public static IReadOnlySet<string> All { get; } = new HashSet<string>(StringComparer.Ordinal)
11+
{
12+
"train_tunnel_display_name", "train_tunnel_link_display_name",
13+
};
14+
}
4.13 KB
Loading
-1.69 KB
Binary file not shown.
2.76 KB
Loading
2.68 KB
Loading
-6.91 KB
Binary file not shown.
-4.3 KB
Binary file not shown.

0 commit comments

Comments
 (0)