Skip to content

Commit a0a3134

Browse files
committed
Redid logic for map image generation.
- Replaced ImageSharp with SkiaSharp. - Changed the ?mapimage command to use a MemoryStream instead of write to a temporary file.
1 parent af54a50 commit a0a3134

File tree

6 files changed

+168
-73
lines changed

6 files changed

+168
-73
lines changed

Diff for: README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ To build the server,
3939
- [Microsoft.Data.Sqlite](https://www.nuget.org/packages/Microsoft.Data.Sqlite) - for an embedded database that persists player and arena data
4040
- [Microsoft.Extensions.ObjectPool](https://www.nuget.org/packages/Microsoft.Extensions.ObjectPool) - object pooling to reduce allocations and the need to garbage collect
4141
- [Microsoft.IO.RecyclableMemoryStream](https://www.nuget.org/packages/Microsoft.IO.RecyclableMemoryStream) - for an improved MemoryStream with regards to performance and garbage collection
42-
- [Npgsql](https://www.nuget.org/packages/Npgsql) - for connecting to a PostgreSQL database (optional)
43-
- [SixLabors.ImageSharp](https://www.nuget.org/packages/SixLabors.ImageSharp) - for creating images of maps
42+
- [Npgsql](https://www.nuget.org/packages/Npgsql) - for connecting to a PostgreSQL database (optional matchmaking functionality)
43+
- [SkiaSharp](https://www.nuget.org/packages/SkiaSharp) - for creating images of maps
4444
- [System.IO.Hashing](https://www.nuget.org/packages/System.IO.Hashing) - for a CRC-32 implementation compatible with zlib's
4545
4646
## License

Diff for: src/Core/ComponentInterfaces/IMapData.cs

+23-9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System;
33
using System.Collections.Generic;
44
using System.Collections.Immutable;
5+
using System.IO;
56

67
namespace SS.Core.ComponentInterfaces
78
{
@@ -151,14 +152,27 @@ public interface IMapData : IComponentInterface
151152
/// <returns>A set of regions (empty if the coordinate is not in a region).</returns>
152153
ImmutableHashSet<MapRegion> RegionsAt(Arena arena, MapCoordinate coord);
153154

154-
/// <summary>
155-
/// Saves an image of the map to a file.
156-
/// </summary>
157-
/// <param name="arena">The arena of the map to save.</param>
158-
/// <param name="path">The path of the file. The image format is automatically determined based on the filename extension.</param>
159-
/// <exception cref="ArgumentNullException">The <paramref name="arena"/> is <see langword="null"/>.</exception>
160-
/// <exception cref="ArgumentException">The <paramref name="path"/> is null or white-space.</exception>
161-
/// <exception cref="NotSupportedException">No encoder available for the provided <paramref name="path"/>.</exception>
162-
void SaveImage(Arena arena, string path);
155+
/// <summary>
156+
/// Saves an image of the map to a file.
157+
/// </summary>
158+
/// <param name="arena">The arena of the map to save.</param>
159+
/// <param name="path">The path of the file. The image format is automatically determined based on the filename extension.</param>
160+
/// <exception cref="ArgumentNullException">The <paramref name="arena"/> is <see langword="null"/>.</exception>
161+
/// <exception cref="ArgumentException">The <paramref name="path"/> is null or white-space.</exception>
162+
/// <exception cref="ArgumentException">The <paramref name="path"/> file extension specifies an unsupported image format.</exception>
163+
/// <exception cref="Exception">Error encoding image.</exception>
164+
void SaveImage(Arena arena, string path);
165+
166+
/// <summary>
167+
/// Saves an image of the map to a file.
168+
/// </summary>
169+
/// <param name="arena">The arena of the map to save.</param>
170+
/// <param name="stream">The stream to write the image data to.</param>
171+
/// <param name="imageFormat">The format to save the image as. Supported formats: 'png', 'jpg', and 'webp'.</param>
172+
/// <exception cref="ArgumentNullException">The <paramref name="arena"/> is <see langword="null"/>.</exception>
173+
/// <exception cref="ArgumentNullException">The <paramref name="stream"/> is <see langword="null"/>.</exception>
174+
/// <exception cref="ArgumentException">Unsupported image format for the provided <paramref name="imageFormat"/>.</exception>
175+
/// <exception cref="Exception">Error encoding image.</exception>
176+
void SaveImage(Arena arena, Stream stream, ReadOnlySpan<char> imageFormat);
163177
}
164178
}

Diff for: src/Core/Core.csproj

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
<PackageReference Include="Google.Protobuf" Version="3.26.1" />
1818
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" />
1919
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="8.0.4" />
20-
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
20+
<PackageReference Include="SkiaSharp" Version="2.88.8" />
21+
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.8" />
2122
<PackageReference Include="System.IO.Hashing" Version="8.0.0" />
2223
</ItemGroup>
2324

Diff for: src/Core/Map/BasicLvl.cs

+109-32
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
using SixLabors.ImageSharp;
2-
using SixLabors.ImageSharp.PixelFormats;
1+
using SkiaSharp;
2+
using SS.Utilities;
33
using System;
44
using System.Collections.Generic;
55
using System.Collections.ObjectModel;
6+
using System.IO;
67
using System.IO.MemoryMappedFiles;
78
using System.Runtime.InteropServices;
89

@@ -180,38 +181,114 @@ protected void ReadPlainTileData(MemoryMappedViewAccessor accessor, long positio
180181
IsTileDataLoaded = true;
181182
}
182183

183-
/// <summary>
184-
/// Creates an image of the map, saving it to a specified <paramref name="path"/>.
185-
/// The image format is automatically determined based on the filename extension.
186-
/// </summary>
187-
/// <param name="path"></param>
188-
/// <exception cref="ArgumentException">The <paramref name="path"/> is null or white-space.</exception>
189-
/// <exception cref="NotSupportedException">No encoder available for the provided <paramref name="path"/>.</exception>
190-
public void SaveImage(string path)
184+
// Note: It seems SkiaSharp only supports encoding to jpg, png, and webp even though it has many other image formats defined.
185+
private static readonly Trie<SKEncodedImageFormat> _extensionToImageFormatTrie = new(false)
191186
{
192-
if (string.IsNullOrWhiteSpace(path))
193-
throw new ArgumentException("A path is required.", nameof(path));
187+
//{ ".bmp", SKEncodedImageFormat.Bmp },
188+
//{ "bmp", SKEncodedImageFormat.Bmp },
189+
//{ ".gif", SKEncodedImageFormat.Gif },
190+
//{ "gif", SKEncodedImageFormat.Gif },
191+
//{ ".ico", SKEncodedImageFormat.Ico},
192+
//{ "ico", SKEncodedImageFormat.Ico},
193+
{ ".jpg", SKEncodedImageFormat.Jpeg },
194+
{ "jpg", SKEncodedImageFormat.Jpeg },
195+
{ ".jpeg", SKEncodedImageFormat.Jpeg },
196+
{ "jpeg", SKEncodedImageFormat.Jpeg },
197+
{ ".png", SKEncodedImageFormat.Png },
198+
{ "png", SKEncodedImageFormat.Png },
199+
{ ".webp", SKEncodedImageFormat.Webp },
200+
{ "webp", SKEncodedImageFormat.Webp },
201+
//{ ".heif", SKEncodedImageFormat.Heif },
202+
//{ "heif", SKEncodedImageFormat.Heif },
203+
};
194204

195-
using Image<Rgb24> image = new(1024, 1024, Color.Black);
205+
/// <summary>
206+
/// Creates an image of the map, saving it to a specified <paramref name="path"/>.
207+
/// </summary>
208+
/// <param name="path">The path to save the file to. The image format is automatically determined based on the filename extension.</param>
209+
/// <exception cref="ArgumentException">The <paramref name="path"/> is null or white-space.</exception>
210+
/// <exception cref="ArgumentException">The <paramref name="path"/> file extension specifies an unsupported image format.</exception>
211+
/// <exception cref="Exception">Error encoding image.</exception>
212+
public void SaveImage(string path)
213+
{
214+
ArgumentException.ThrowIfNullOrWhiteSpace(path);
196215

197-
foreach (KeyValuePair<MapCoordinate, MapTile> kvp in _tileLookup)
198-
{
199-
Color color = kvp.Value switch
200-
{
201-
{ IsDoor: true } => Color.Blue,
202-
{ IsSafe: true } => Color.LightGreen,
203-
{ IsTurfFlag: true } => Color.Yellow,
204-
{ IsGoal: true } => Color.Red,
205-
{ IsWormhole: true } => Color.Purple,
206-
{ IsFlyOver: true } => Color.DarkGray,
207-
{ IsFlyUnder: true } => Color.DarkGray,
208-
_ => Color.White
209-
};
210-
211-
image[kvp.Key.X, kvp.Key.Y] = color;
212-
}
216+
string extension = Path.GetExtension(path);
217+
if (!_extensionToImageFormatTrie.TryGetValue(extension, out SKEncodedImageFormat format))
218+
throw new ArgumentException("Unsupported image format.", nameof(path));
213219

214-
image.Save(path);
215-
}
216-
}
220+
using SKBitmap bitmap = CreateBitmap();
221+
222+
bool success = false;
223+
224+
using (FileStream fs = new(path, FileMode.CreateNew))
225+
{
226+
success = bitmap.Encode(fs, format, 100);
227+
}
228+
229+
if (!success)
230+
{
231+
try
232+
{
233+
File.Delete(path);
234+
}
235+
catch
236+
{
237+
}
238+
239+
throw new Exception($"Error encoding as {format}.");
240+
}
241+
}
242+
243+
/// <summary>
244+
/// Creates an image of the map, saving it to a specified <paramref name="path"/>.
245+
/// </summary>
246+
/// <param name="imageFormat">The format to save the image as.</param>
247+
/// <exception cref="ArgumentException">The <paramref name="imageFormat"/> is white-space.</exception>
248+
/// <exception cref="ArgumentException">Unsupported image format for the provided <paramref name="imageFormat"/>.</exception>
249+
/// <exception cref="Exception">Error encoding image.</exception>
250+
public void SaveImage(Stream stream, ReadOnlySpan<char> imageFormat)
251+
{
252+
ArgumentNullException.ThrowIfNull(stream);
253+
254+
if (imageFormat.IsWhiteSpace())
255+
throw new ArgumentException("Cannot be whitespace.", nameof(imageFormat));
256+
257+
if (!_extensionToImageFormatTrie.TryGetValue(imageFormat, out SKEncodedImageFormat format))
258+
throw new ArgumentException("Unsupported image format.", nameof(imageFormat));
259+
260+
using SKBitmap bitmap = CreateBitmap();
261+
262+
if (!bitmap.Encode(stream, format, 100))
263+
throw new Exception($"Error encoding as {format}.");
264+
}
265+
266+
private SKBitmap CreateBitmap()
267+
{
268+
SKImageInfo info = new(1024, 1024);
269+
SKBitmap bitmap = new(info);
270+
271+
using SKCanvas canvas = new(bitmap);
272+
canvas.Clear(SKColors.Black);
273+
274+
foreach (KeyValuePair<MapCoordinate, MapTile> kvp in _tileLookup)
275+
{
276+
SKColor color = kvp.Value switch
277+
{
278+
{ IsDoor: true } => SKColors.Blue,
279+
{ IsSafe: true } => SKColors.LightGreen,
280+
{ IsTurfFlag: true } => SKColors.Yellow,
281+
{ IsGoal: true } => SKColors.Red,
282+
{ IsWormhole: true } => SKColors.Purple,
283+
{ IsFlyOver: true } => SKColors.DarkGray,
284+
{ IsFlyUnder: true } => SKColors.DarkGray,
285+
_ => SKColors.White
286+
};
287+
288+
canvas.DrawPoint(kvp.Key.X, kvp.Key.Y, color);
289+
}
290+
291+
return bitmap;
292+
}
293+
}
217294
}

Diff for: src/Core/Modules/MapData.cs

+24-7
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,8 @@ ImmutableHashSet<MapRegion> IMapData.RegionsAt(Arena arena, short x, short y)
455455

456456
void IMapData.SaveImage(Arena arena, string path)
457457
{
458-
if (arena == null)
459-
throw new ArgumentNullException(nameof(arena));
460-
461-
if (string.IsNullOrWhiteSpace(path))
462-
throw new ArgumentException("A path is required.", nameof(path));
458+
ArgumentNullException.ThrowIfNull(arena);
459+
ArgumentException.ThrowIfNullOrWhiteSpace(path);
463460

464461
if (!arena.TryGetExtraData(_adKey, out ArenaData ad))
465462
throw new Exception("missing lvl data");
@@ -476,9 +473,29 @@ void IMapData.SaveImage(Arena arena, string path)
476473
}
477474
}
478475

479-
#endregion
476+
void IMapData.SaveImage(Arena arena, Stream stream, ReadOnlySpan<char> imageFormat)
477+
{
478+
ArgumentNullException.ThrowIfNull(arena);
479+
ArgumentNullException.ThrowIfNull(stream);
480+
481+
if (!arena.TryGetExtraData(_adKey, out ArenaData ad))
482+
throw new Exception("missing lvl data");
483+
484+
ad.Lock.EnterReadLock();
485+
486+
try
487+
{
488+
ad.Lvl.SaveImage(stream, imageFormat);
489+
}
490+
finally
491+
{
492+
ad.Lock.ExitReadLock();
493+
}
494+
}
495+
496+
#endregion
480497

481-
private async void Callback_ArenaAction(Arena arena, ArenaAction action)
498+
private async void Callback_ArenaAction(Arena arena, ArenaAction action)
482499
{
483500
if (arena == null)
484501
return;

Diff for: src/Core/Modules/PlayerCommand.cs

+8-22
Original file line numberDiff line numberDiff line change
@@ -3260,7 +3260,7 @@ private void Command_mapinfo(ReadOnlySpan<char> command, ReadOnlySpan<char> para
32603260
Args = "[<image file extension>]",
32613261
Description = $"""
32623262
Downloads an image of the map.
3263-
The image format can optionally be specified. The default is '{DefaultMapImageFormat}'.
3263+
Available formats: png (default), jpg, and webp.
32643264
""")]
32653265
private void Command_mapimage(ReadOnlySpan<char> command, ReadOnlySpan<char> parameters, Player player, ITarget target)
32663266
{
@@ -3296,40 +3296,26 @@ private void Command_mapimage(ReadOnlySpan<char> command, ReadOnlySpan<char> par
32963296
if (!imageFileName.TryWrite($"{fileNameWithoutExtension}.{extension}", out int charsWritten) || imageFileName.Length != charsWritten)
32973297
return;
32983298

3299-
// Create file name for temp file.
3300-
const string prefix = "mapimage-";
3301-
Span<char> tempFileName = stackalloc char[prefix.Length + 32 + 1 + extension.Length];
3302-
if (!tempFileName.TryWrite($"{prefix}{Guid.NewGuid():N}.{extension}", out charsWritten) || tempFileName.Length != charsWritten)
3303-
return;
3304-
3305-
string path = Path.Join("tmp", tempFileName);
3306-
3299+
MemoryStream stream = new();
3300+
33073301
// Create the image of the map.
3308-
// TODO: Use a worker thread to do file I/O, including the call to _fileTransfer.SendFile.
33093302
try
33103303
{
3311-
_mapData.SaveImage(arena, path);
3304+
_mapData.SaveImage(arena, stream, extension);
3305+
stream.Position = 0;
33123306
}
33133307
catch (Exception ex)
33143308
{
3309+
stream.Dispose();
33153310
_chat.SendMessage(player, $"Error saving image.");
33163311
_chat.SendWrappedText(player, ex.Message);
33173312
return;
33183313
}
33193314

33203315
// Send the image.
3321-
if (!_fileTransfer.SendFile(player, path, imageFileName, true))
3316+
if (!_fileTransfer.SendFile(player, stream, imageFileName))
33223317
{
3323-
// Cleanup the temp file.
3324-
try
3325-
{
3326-
File.Delete(path);
3327-
}
3328-
catch(Exception ex)
3329-
{
3330-
_logManager.LogP(LogLevel.Warn, nameof(PlayerCommand), player, $"Failed to delete temporary image file '{path}'. {ex.Message}");
3331-
}
3332-
3318+
stream.Dispose();
33333319
_chat.SendMessage(player, $"Error sending image.");
33343320
return;
33353321
}

0 commit comments

Comments
 (0)