diff --git a/Content.MapRenderer/Content.MapRenderer.csproj b/Content.MapRenderer/Content.MapRenderer.csproj new file mode 100644 index 0000000000..32f79a2821 --- /dev/null +++ b/Content.MapRenderer/Content.MapRenderer.csproj @@ -0,0 +1,20 @@ + + + + Exe + net6.0 + ..\bin\Content.MapRenderer\ + false + enable + + + + + + + + + + + + diff --git a/Content.MapRenderer/Extensions/DirectoryExtensions.cs b/Content.MapRenderer/Extensions/DirectoryExtensions.cs new file mode 100644 index 0000000000..55d4611a7e --- /dev/null +++ b/Content.MapRenderer/Extensions/DirectoryExtensions.cs @@ -0,0 +1,32 @@ +using System.IO; +using System.Reflection; + +namespace Content.MapRenderer.Extensions +{ + public static class DirectoryExtensions + { + public static DirectoryInfo RepositoryRoot() + { + // space-station-14/bin/Content.MapRenderer/Content.MapRenderer.dll + var currentLocation = Assembly.GetExecutingAssembly().Location; + + // space-station-14 + return Directory.GetParent(currentLocation)!.Parent!.Parent!; + } + + public static DirectoryInfo Resources() + { + return new DirectoryInfo($"{RepositoryRoot()}{Path.DirectorySeparatorChar}Resources"); + } + + public static DirectoryInfo Maps() + { + return new DirectoryInfo($"{Resources()}{Path.DirectorySeparatorChar}Maps"); + } + + public static DirectoryInfo MapImages() + { + return new DirectoryInfo($"{Resources()}{Path.DirectorySeparatorChar}MapImages"); + } + } +} diff --git a/Content.MapRenderer/Extensions/EnvironmentExtensions.cs b/Content.MapRenderer/Extensions/EnvironmentExtensions.cs new file mode 100644 index 0000000000..34a379096f --- /dev/null +++ b/Content.MapRenderer/Extensions/EnvironmentExtensions.cs @@ -0,0 +1,20 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Content.MapRenderer.Extensions +{ + public static class EnvironmentExtensions + { + public static bool TryGetVariable(string key, [NotNullWhen(true)] out string? value) + { + return (value = Environment.GetEnvironmentVariable(key)) != null; + } + + public static string GetVariableOrThrow(string key) + { + return Environment.GetEnvironmentVariable(key) ?? + throw new ArgumentException($"No environment variable found with key {key}"); + } + } +} diff --git a/Content.MapRenderer/Painters/EntityData.cs b/Content.MapRenderer/Painters/EntityData.cs new file mode 100644 index 0000000000..95d8b3fd0f --- /dev/null +++ b/Content.MapRenderer/Painters/EntityData.cs @@ -0,0 +1,20 @@ +using Robust.Client.GameObjects; + +namespace Content.MapRenderer.Painters +{ + public class EntityData + { + public EntityData(SpriteComponent sprite, float x, float y) + { + Sprite = sprite; + X = x; + Y = y; + } + + public SpriteComponent Sprite { get; } + + public float X { get; } + + public float Y { get; } + } +} diff --git a/Content.MapRenderer/Painters/EntityPainter.cs b/Content.MapRenderer/Painters/EntityPainter.cs new file mode 100644 index 0000000000..13dcd6bf73 --- /dev/null +++ b/Content.MapRenderer/Painters/EntityPainter.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using Content.Shared.SubFloor; +using Robust.Client.ResourceManagement; +using Robust.Shared.GameObjects; +using Robust.Shared.Timing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using static Robust.Client.Graphics.RSI.State; +using static Robust.UnitTesting.RobustIntegrationTest; + +namespace Content.MapRenderer.Painters; + +public class EntityPainter +{ + private readonly IResourceCache _cResourceCache; + + private readonly Dictionary<(string path, string state), Image> _images; + private readonly Image _errorImage; + + private readonly IEntityManager _sEntityManager; + + public EntityPainter(ClientIntegrationInstance client, ServerIntegrationInstance server) + { + _cResourceCache = client.ResolveDependency(); + + _sEntityManager = server.ResolveDependency(); + + _images = new Dictionary<(string path, string state), Image>(); + _errorImage = Image.Load(_cResourceCache.ContentFileRead("/Textures/error.rsi/error.png")); + } + + public void Run(Image canvas, List entities) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + // TODO cache this shit what are we insane + entities.Sort(Comparer.Create((x, y) => x.Sprite.DrawDepth.CompareTo(y.Sprite.DrawDepth))); + + foreach (var entity in entities) + { + Run(canvas, entity); + } + + Console.WriteLine($"{nameof(GridPainter)} painted {entities.Count} entities in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + } + + public void Run(Image canvas, EntityData entity) + { + if (_sEntityManager.HasComponent(entity.Sprite.Owner)) + { + return; + } + + if (!entity.Sprite.Visible || entity.Sprite.ContainerOccluded) + { + return; + } + + var worldRotation = _sEntityManager.GetComponent(entity.Sprite.Owner).WorldRotation; + foreach (var layer in entity.Sprite.AllLayers) + { + if (!layer.Visible) + { + continue; + } + + if (!layer.RsiState.IsValid) + { + continue; + } + + var rsi = layer.ActualRsi; + Image image; + + if (rsi == null || rsi.Path == null || !rsi.TryGetState(layer.RsiState, out var state)) + { + image = _errorImage; + } + else + { + var key = (rsi.Path!.ToString(), state.StateId.Name!); + + if (!_images.TryGetValue(key, out image!)) + { + var stream = _cResourceCache.ContentFileRead($"{rsi.Path}/{state.StateId}.png"); + image = Image.Load(stream); + + _images[key] = image; + } + } + + image = image.CloneAs(); + + var directions = entity.Sprite.GetLayerDirectionCount(layer); + + // TODO add support for 8 directions and animations (delays) + if (directions != 1 && directions != 8) + { + double xStart, xEnd, yStart, yEnd; + + switch (directions) + { + case 4: + { + var dir = layer.EffectiveDirection(worldRotation); + + (xStart, xEnd, yStart, yEnd) = dir switch + { + // Only need the first tuple as doubles for the compiler to recognize it + Direction.South => (0d, 0.5d, 0d, 0.5d), + Direction.East => (0, 0.5, 0.5, 1), + Direction.North => (0.5, 1, 0, 0.5), + Direction.West => (0.5, 1, 0.5, 1), + _ => throw new ArgumentOutOfRangeException(nameof(dir)) + }; + break; + } + default: + throw new ArgumentOutOfRangeException(); + } + + var x = (int) (image.Width * xStart); + var width = (int) (image.Width * xEnd) - x; + + var y = (int) (image.Height * yStart); + var height = (int) (image.Height * yEnd) - y; + + image.Mutate(o => o.Crop(new Rectangle(x, y, width, height))); + } + + var colorMix = entity.Sprite.Color * layer.Color; + var imageColor = Color.FromRgba(colorMix.RByte, colorMix.GByte, colorMix.BByte, colorMix.AByte); + var coloredImage = new Image(image.Width, image.Height); + coloredImage.Mutate(o => o.BackgroundColor(imageColor)); + + image.Mutate(o => o + .DrawImage(coloredImage, PixelColorBlendingMode.Multiply, PixelAlphaCompositionMode.SrcAtop, 1) + .Resize(32, 32) + .Flip(FlipMode.Vertical)); + + var pointX = (int) entity.X; + var pointY = (int) entity.Y; + canvas.Mutate(o => o.DrawImage(image, new Point(pointX, pointY), 1)); + } + } +} diff --git a/Content.MapRenderer/Painters/GridPainter.cs b/Content.MapRenderer/Painters/GridPainter.cs new file mode 100644 index 0000000000..f009ce9660 --- /dev/null +++ b/Content.MapRenderer/Painters/GridPainter.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Timing; +using SixLabors.ImageSharp; +using static Robust.UnitTesting.RobustIntegrationTest; + +namespace Content.MapRenderer.Painters +{ + public class GridPainter + { + private readonly EntityPainter _entityPainter; + + private readonly IEntityManager _cEntityManager; + private readonly IMapManager _cMapManager; + + private readonly IEntityManager _sEntityManager; + + private readonly ConcurrentDictionary> _entities; + + public GridPainter(ClientIntegrationInstance client, ServerIntegrationInstance server) + { + _entityPainter = new EntityPainter(client, server); + + _cEntityManager = client.ResolveDependency(); + _cMapManager = client.ResolveDependency(); + + _sEntityManager = server.ResolveDependency(); + + _entities = GetEntities(); + } + + public void Run(Image gridCanvas, IMapGrid grid) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + if (!_entities.TryGetValue(grid.Index, out var entities)) + { + Console.WriteLine($"No entities found on grid {grid.Index}"); + return; + } + + _entityPainter.Run(gridCanvas, entities); + Console.WriteLine($"{nameof(GridPainter)} painted grid {grid.Index} in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + } + + private ConcurrentDictionary> GetEntities() + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + var components = new ConcurrentDictionary>(); + + foreach (var entity in _sEntityManager.GetEntities()) + { + if (!_sEntityManager.HasComponent(entity)) + { + continue; + } + + var prototype = _sEntityManager.GetComponent(entity).EntityPrototype; + if (prototype == null) + { + continue; + } + + if (!_cEntityManager.TryGetComponent(entity, out SpriteComponent sprite)) + { + throw new InvalidOperationException( + $"No sprite component found on an entity for which a server sprite component exists. Prototype id: {prototype.ID}"); + } + + var xOffset = 0; + var yOffset = 0; + var tileSize = 1; + + var transform = _sEntityManager.GetComponent(entity); + if (_cMapManager.TryGetGrid(transform.GridID, out var grid)) + { + xOffset = (int) Math.Abs(grid.LocalBounds.Left); + yOffset = (int) Math.Abs(grid.LocalBounds.Bottom); + tileSize = grid.TileSize; + } + + var position = transform.LocalPosition; + var x = ((float) Math.Floor(position.X) + xOffset) * tileSize * TilePainter.TileImageSize; + var y = ((float) Math.Floor(position.Y) + yOffset) * tileSize * TilePainter.TileImageSize; + var data = new EntityData(sprite, x, y); + + components.GetOrAdd(transform.GridID, _ => new List()).Add(data); + } + + Console.WriteLine($"Found {components.Values.Sum(l => l.Count)} entities on {components.Count} grids in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + + return components; + } + } +} diff --git a/Content.MapRenderer/Painters/MapPainter.cs b/Content.MapRenderer/Painters/MapPainter.cs new file mode 100644 index 0000000000..cb2d1bd83b --- /dev/null +++ b/Content.MapRenderer/Painters/MapPainter.cs @@ -0,0 +1,151 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Content.IntegrationTests; +using Content.Shared.CCVar; +using Robust.Client.GameObjects; +using Robust.Server.GameObjects; +using Robust.Server.Player; +using Robust.Shared; +using Robust.Shared.Log; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Timing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SpriteComponent = Robust.Server.GameObjects.SpriteComponent; + +namespace Content.MapRenderer.Painters +{ + public class MapPainter : ContentIntegrationTest + { + public async IAsyncEnumerable Paint(string map) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + var clientOptions = new ClientContentIntegrationOption + { + CVarOverrides = + { + [CVars.NetPVS.Name] = "false" + }, + Pool = false, + FailureLogLevel = LogLevel.Fatal + }; + + var serverOptions = new ServerContentIntegrationOption + { + CVarOverrides = + { + [CCVars.GameMap.Name] = map, + [CVars.NetPVS.Name] = "false" + }, + Pool = false, + FailureLogLevel = LogLevel.Fatal + }; + + var (client, server) = await StartConnectedServerClientPair(clientOptions, serverOptions); + + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + await RunTicksSync(client, server, 10); + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + + Console.WriteLine($"Loaded client and server in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + + stopwatch.Restart(); + + var cEntityManager = client.ResolveDependency(); + var cPlayerManager = client.ResolveDependency(); + + await client.WaitPost(() => + { + if (cEntityManager.TryGetComponent(cPlayerManager.LocalPlayer!.ControlledEntity!, out Robust.Client.GameObjects.SpriteComponent? sprite)) + { + sprite.Visible = false; + } + }); + + var sEntityManager = server.ResolveDependency(); + var sPlayerManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + if (sEntityManager.TryGetComponent(sPlayerManager.ServerSessions.Single().AttachedEntity!, out SpriteComponent? sprite)) + { + sprite.Visible = false; + } + }); + + await RunTicksSync(client, server, 10); + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + + var sMapManager = server.ResolveDependency(); + + var tilePainter = new TilePainter(client, server); + var entityPainter = new GridPainter(client, server); + IMapGrid[] grids = null!; + + await server.WaitPost(() => + { + var playerEntity = sPlayerManager.ServerSessions.Single().AttachedEntity; + + if (playerEntity.HasValue) + { + sEntityManager.DeleteEntity(playerEntity.Value); + } + + grids = sMapManager.GetAllMapGrids(new MapId(1)).ToArray(); + + foreach (var grid in grids) + { + grid.WorldRotation = Angle.Zero; + } + }); + + await RunTicksSync(client, server, 10); + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + + foreach (var grid in grids) + { + var tileXSize = grid.TileSize * TilePainter.TileImageSize; + var tileYSize = grid.TileSize * TilePainter.TileImageSize; + + var bounds = grid.LocalBounds; + + var left = bounds.Left; + var right = bounds.Right; + var top = bounds.Top; + var bottom = bounds.Bottom; + + var w = (int) Math.Ceiling(right - left) * tileXSize; + var h = (int) Math.Ceiling(top - bottom) * tileYSize; + + var gridCanvas = new Image(w, h); + + await server.WaitPost(() => + { + tilePainter.Run(gridCanvas, grid); + entityPainter.Run(gridCanvas, grid); + + gridCanvas.Mutate(e => e.Flip(FlipMode.Vertical)); + }); + + yield return gridCanvas; + } + + // We don't care if it fails as we have already saved the images. + try + { + await OneTimeTearDown(); + } + catch + { + // ignored + } + } + } +} diff --git a/Content.MapRenderer/Painters/TilePainter.cs b/Content.MapRenderer/Painters/TilePainter.cs new file mode 100644 index 0000000000..9f6cde3779 --- /dev/null +++ b/Content.MapRenderer/Painters/TilePainter.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Shared.Map; +using Robust.Shared.Timing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using static Robust.UnitTesting.RobustIntegrationTest; + +namespace Content.MapRenderer.Painters +{ + public class TilePainter + { + private const string TilesPath = "/Textures/Tiles/"; + public const int TileImageSize = EyeManager.PixelsPerMeter; + + private readonly ITileDefinitionManager _sTileDefinitionManager; + private readonly IResourceCache _cResourceCache; + + public TilePainter(ClientIntegrationInstance client, ServerIntegrationInstance server) + { + _sTileDefinitionManager = server.ResolveDependency(); + _cResourceCache = client.ResolveDependency(); + } + + public void Run(Image gridCanvas, IMapGrid grid) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + var bounds = grid.LocalBounds; + var xOffset = Math.Abs(bounds.Left); + var yOffset = Math.Abs(bounds.Bottom); + var tileSize = grid.TileSize * TileImageSize; + + var images = GetTileImages(_sTileDefinitionManager, _cResourceCache, tileSize); + var i = 0; + + grid.GetAllTiles().AsParallel().ForAll(tile => + { + var x = (int) (tile.X + xOffset); + var y = (int) (tile.Y + yOffset); + var sprite = _sTileDefinitionManager[tile.Tile.TypeId].SpriteName; + var image = images[sprite]; + + gridCanvas.Mutate(o => o.DrawImage(image, new Point(x * tileSize, y * tileSize), 1)); + + i++; + }); + + Console.WriteLine($"{nameof(TilePainter)} painted {i} tiles on grid {grid.Index} in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + } + + private Dictionary GetTileImages( + ITileDefinitionManager tileDefinitionManager, + IResourceCache resourceCache, + int tileSize) + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + var images = new Dictionary(); + + foreach (var definition in tileDefinitionManager) + { + var sprite = definition.SpriteName; + + if (string.IsNullOrEmpty(sprite)) + { + continue; + } + + using var stream = resourceCache.ContentFileRead($"{TilesPath}{sprite}.png"); + Image tileImage = Image.Load(stream); + + if (tileImage.Width != tileSize || tileImage.Height != tileSize) + { + throw new NotSupportedException($"Unable to use tiles with a dimension other than {tileSize}x{tileSize}."); + } + + images[sprite] = tileImage; + } + + Console.WriteLine($"Indexed all tile images in {(int) stopwatch.Elapsed.TotalMilliseconds} ms"); + + return images; + } + } +} diff --git a/Content.MapRenderer/Program.cs b/Content.MapRenderer/Program.cs new file mode 100644 index 0000000000..e536f7e86c --- /dev/null +++ b/Content.MapRenderer/Program.cs @@ -0,0 +1,102 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Content.MapRenderer.Extensions; +using Content.MapRenderer.Painters; +using SixLabors.ImageSharp; + +namespace Content.MapRenderer +{ + internal class Program + { + private const string MapsAddedEnvKey = "FILES_ADDED"; + private const string MapsModifiedEnvKey = "FILES_MODIFIED"; + + private static readonly MapPainter MapPainter = new(); + +#pragma warning disable CA1825 + private static readonly string[] ForceRender = {"saltern"}; +#pragma warning restore CA1825 + + internal static async Task Main() + { + await Run(); + } + + private static async Task Run() + { + // var created = Environment.GetEnvironmentVariable(MapsAddedEnvKey); + // var modified = Environment.GetEnvironmentVariable(MapsModifiedEnvKey); + // + // var yamlStream = new YamlStream(); + // + // if (created != null) + // { + // yamlStream.Load(new StringReader(created)); + // } + // + // if (modified != null) + // { + // yamlStream.Load(new StringReader(modified)); + // } + // + // var files = new YamlSequenceNode(); + // + // foreach (var doc in yamlStream.Documents) + // { + // var filesModified = (YamlSequenceNode) doc.RootNode; + // + // foreach (var node in filesModified) + // { + // files.Add(node); + // } + // } + + var maps = new List(ForceRender); + + // foreach (var node in files) + // { + // var fileName = node.AsString(); + // + // if (!fileName.StartsWith("Resources/Maps/") || + // !fileName.EndsWith("yml")) + // { + // continue; + // } + // + // maps.Add(fileName); + // } + + Console.WriteLine($"Creating images for {maps.Count} maps"); + + var mapNames = new List(); + foreach (var map in maps) + { + Console.WriteLine($"Painting map {map}"); + + await foreach (var grid in MapPainter.Paint(map)) + { + var directory = DirectoryExtensions.MapImages().FullName; + Directory.CreateDirectory(directory); + + var fileName = Path.GetFileNameWithoutExtension(map); + var savePath = $"{directory}{Path.DirectorySeparatorChar}{fileName}.png"; + + Console.WriteLine($"Writing grid of size {grid.Width}x{grid.Height} to {savePath}"); + + await grid.SaveAsPngAsync(savePath); + grid.Dispose(); + + mapNames.Add(fileName); + } + } + + var mapNamesString = $"[{string.Join(',', mapNames.Select(s => $"\"{s}\""))}]"; + Console.WriteLine($@"::set-output name=map_names::{mapNamesString}"); + Console.WriteLine($"Created {maps.Count} map images."); + } + } +} diff --git a/SpaceStation14.sln b/SpaceStation14.sln index 23818d7276..d678ffce63 100644 --- a/SpaceStation14.sln +++ b/SpaceStation14.sln @@ -100,6 +100,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pow3r", "Pow3r\Pow3r.csproj EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.Shared.Database", "Content.Shared.Database\Content.Shared.Database.csproj", "{8842381D-3426-4BA8-93DA-599AB14D88E9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Content.MapRenderer", "Content.MapRenderer\Content.MapRenderer.csproj", "{199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -233,6 +235,10 @@ Global {8842381D-3426-4BA8-93DA-599AB14D88E9}.Debug|Any CPU.Build.0 = Debug|Any CPU {8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.ActiveCfg = Release|Any CPU {8842381D-3426-4BA8-93DA-599AB14D88E9}.Release|Any CPU.Build.0 = Release|Any CPU + {199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {199BBEA1-7627-434B-B6F6-0F52A7C0E1E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SpaceStation14.sln.DotSettings b/SpaceStation14.sln.DotSettings index 3287ea383e..da231d591f 100644 --- a/SpaceStation14.sln.DotSettings +++ b/SpaceStation14.sln.DotSettings @@ -257,6 +257,7 @@ True True True + True True True True