diff --git a/.gitignore b/.gitignore index 5d4c06f8..9ba11333 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,8 @@ dist/ ## Experimental mobile hosts (platforms/) platforms/android/native/out/ + +# Local promotional media (trailers, channel art): kept on the maintainer's machine, never in the repository +/trailer/ +/trailer-*/ +/channel-art/ diff --git a/AGENTS.md b/AGENTS.md index ee652c15..b03d8597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ sufficient without previous chats or agent memory. - The active rebuild record (specs, tasks, log) is [`odd/tasks/engine-rebuild.md`](odd/tasks/engine-rebuild.md); the latest feature records are [`odd/tasks/docs-1.md`](odd/tasks/docs-1.md), [`odd/tasks/sdk-1.md`](odd/tasks/sdk-1.md), [`odd/tasks/input-1.md`](odd/tasks/input-1.md), [`odd/tasks/product-1.md`](odd/tasks/product-1.md), - [`odd/tasks/asset-1.md`](odd/tasks/asset-1.md) (the asset pipeline, ADR 0025) and - [`odd/tasks/demo-refresh.md`](odd/tasks/demo-refresh.md) (the four demos modernized, new art for the two hybrids). + [`odd/tasks/asset-1.md`](odd/tasks/asset-1.md) (the asset pipeline, ADR 0025), + [`odd/tasks/demo-refresh.md`](odd/tasks/demo-refresh.md) (the four demos modernized, new art for the two hybrids) and + [`odd/tasks/tile-1.md`](odd/tasks/tile-1.md) (maps from the Tiled editor, the optional `MicroEngine.Tiled` package, ADR 0052). - CI ([docs/ci.md](docs/ci.md), ADR 0013): `ci.yml` runs the checks a change needs, once per content (evidence), the SDK check included; `validate.yml` runs everything on demand; `platforms.yml` holds the monthly and manual macOS, Android and iOS experiments of [`platforms/`](platforms/README.md), which are not supported platforms. Demos diff --git a/MicroEngine.slnx b/MicroEngine.slnx index 10aae080..7f364e6b 100644 --- a/MicroEngine.slnx +++ b/MicroEngine.slnx @@ -16,6 +16,7 @@ + @@ -34,6 +35,7 @@ + @@ -44,6 +46,7 @@ + @@ -63,6 +66,7 @@ + diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index ebe71f48..bce913ae 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -73,6 +73,14 @@ Games that add the optional `MicroEngine.Physics.Box2D` package also ship Box2D, | --- | --- | --- | --- | | Box2D 3.2.0 | 2D rigid-body physics: dynamic bodies, collision, queries and the character mover | MIT | Copyright (c) 2022 Erin Catto | +## Optional Tiled maps + +Games that add the optional `MicroEngine.Tiled` package also ship DotTiled, a managed library, inside their build. + +| Component | Use | Licence | Copyright | +| --- | --- | --- | --- | +| DotTiled 1.0.0 | Reading maps, tilesets and templates saved by the Tiled editor (TMX, TMJ, TSX, TSJ, TX, TJ) | MIT | Copyright (c) 2025 Daniel Cronqvist | + ## Browser builds of the demos Each browser package also contains the .NET runtime compiled to WebAssembly and code generated by Emscripten. The diff --git a/benchmarks/MapBench/MapBench.csproj b/benchmarks/MapBench/MapBench.csproj new file mode 100644 index 00000000..935a751a --- /dev/null +++ b/benchmarks/MapBench/MapBench.csproj @@ -0,0 +1,12 @@ + + + Exe + MapBench + Times large Tiled maps: loading and parsing, and drawing what a camera shows at zoom 1 and 0.5. + + false + + + + + diff --git a/benchmarks/MapBench/Program.cs b/benchmarks/MapBench/Program.cs new file mode 100644 index 00000000..7a62e088 --- /dev/null +++ b/benchmarks/MapBench/Program.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Numerics; +using System.Text.Json; +using MicroEngine; +using MicroEngine.Core; +using MicroEngine.Graphics; +using MicroEngine.Tiled; + +// Times large Tiled maps (TILE-1's measurements): +// +// MapBench [--size N] [--layers L] [--runs R] +// +// Writes an N × N map of 16-pixel tiles (1000 × 1000 by default) with L tile layers (3), each base64 and zlib +// compressed as Tiled saves it, then times loading it through the asset cache (parsing, decompressing, building the +// layers) and one frame of drawing every layer with a 320 × 180 camera at zoom 1 and zoom 0.5, on the headless +// renderer: the CPU cost of culling and submitting, not the GPU's. Each timing is the median of R runs (5) after one +// warm-up run; allocations are measured on this thread. +int size = 1000, layers = 3, runs = 5; +for (int i = 0; i < args.Length; i++) + switch (args[i]) + { + case "--size": size = int.Parse(args[++i], CultureInfo.InvariantCulture); break; + case "--layers": layers = int.Parse(args[++i], CultureInfo.InvariantCulture); break; + case "--runs": runs = int.Parse(args[++i], CultureInfo.InvariantCulture); break; + } + +var folder = Directory.CreateTempSubdirectory("microengine-mapbench"); +try +{ + long fileBytes = WriteMap(folder.FullName, size, layers); + Console.WriteLine($"# MapBench: {size} × {size} tiles, {layers} layers, file {fileBytes / 1024.0:0} KB, .NET {Environment.Version}, {runs} runs (median)"); + Console.WriteLine(); + Console.WriteLine("| Measurement | Median | Allocated |"); + Console.WriteLine("| --- | ---: | ---: |"); + + var device = new HeadlessGraphicsDevice(); + TiledMap map = null!; + Row("Load the map (parse, decompress, build layers)", () => + { + using var assets = new AssetCache(device, folder.FullName); + map = assets.TiledMap("map.tmj"); + }); + var keep = new AssetCache(device, folder.FullName); + map = keep.TiledMap("map.tmj"); + var sprites = new TiledSprites(map, keep); + var renderer = new HeadlessRenderer(320, 180); + foreach (float zoom in new[] { 1f, 0.5f }) + { + var camera = new Camera2D(320, 180) { Zoom = zoom }; + camera.CenterOn(new Vector2(map.PixelWidth / 2f, map.PixelHeight / 2f)); + renderer.Reset(); + sprites.DrawAll(renderer, camera); + int draws = renderer.DrawCalls; + Row(string.Create(CultureInfo.InvariantCulture, $"Draw one frame at zoom {zoom} ({draws} tiles)"), () => + { + renderer.Reset(); + sprites.DrawAll(renderer, camera); + }, "µs", 1000); + } + long cells = (long)size * size * layers; + Console.WriteLine(); + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"Layer memory: {cells * (sizeof(int) + 1) / (1024.0 * 1024):0.0} MB ({cells:N0} cells: an id and an orientation each).")); +} +finally { folder.Delete(recursive: true); } + +void Row(string name, Action action, string unit = "ms", double scale = 1) +{ + action(); + var times = new List(); + long allocated = 0; + for (int i = 0; i < runs; i++) + { + long before = GC.GetAllocatedBytesForCurrentThread(); + var watch = Stopwatch.StartNew(); + action(); + times.Add(watch.Elapsed.TotalMilliseconds * scale); + allocated = GC.GetAllocatedBytesForCurrentThread() - before; + } + times.Sort(); + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"| {name} | {times[times.Count / 2]:0.##} {unit} | {allocated / 1024.0:0} KB |")); +} + +// A map as Tiled saves it: one embedded tileset over a 64 × 64 image of four tiles, and every layer filled with a +// pattern of all four (empty cells included), base64 over zlib. +static long WriteMap(string folder, int size, int layers) +{ + var image = new PixelImage(64, 64); + for (int y = 0; y < 64; y++) + for (int x = 0; x < 64; x++) image[x, y] = new Color((byte)(x * 4), (byte)(y * 4), 128); + File.WriteAllBytes(Path.Combine(folder, "tiles.png"), PngEncoder.Encode(image.ToRgba(), image.Width, image.Height)); + var layerList = new List(); + for (int l = 0; l < layers; l++) + { + var raw = new byte[size * size * 4]; + for (int i = 0; i < size * size; i++) BitConverter.TryWriteBytes(raw.AsSpan(i * 4), (uint)((i * 7 + l * 3) % 17 % 5)); + using var packed = new MemoryStream(); + using (var z = new ZLibStream(packed, CompressionLevel.Fastest)) z.Write(raw); + layerList.Add(new Dictionary + { + ["type"] = "tilelayer", ["id"] = l + 1, ["name"] = $"layer{l}", ["x"] = 0, ["y"] = 0, ["width"] = size, ["height"] = size, + ["opacity"] = 1, ["visible"] = true, ["encoding"] = "base64", ["compression"] = "zlib", ["data"] = Convert.ToBase64String(packed.ToArray()), + }); + } + var map = new Dictionary + { + ["type"] = "map", ["version"] = "1.10", ["tiledversion"] = "1.11.0", ["orientation"] = "orthogonal", ["renderorder"] = "right-down", + ["width"] = size, ["height"] = size, ["tilewidth"] = 16, ["tileheight"] = 16, ["infinite"] = false, ["compressionlevel"] = -1, + ["nextlayerid"] = layers + 1, ["nextobjectid"] = 1, + ["tilesets"] = new object[] { new Dictionary + { + ["firstgid"] = 1, ["name"] = "tiles", ["tilewidth"] = 16, ["tileheight"] = 16, ["tilecount"] = 16, ["columns"] = 4, + ["image"] = "tiles.png", ["imagewidth"] = 64, ["imageheight"] = 64, ["margin"] = 0, ["spacing"] = 0, + } }, + ["layers"] = layerList, + }; + string path = Path.Combine(folder, "map.tmj"); + File.WriteAllText(path, JsonSerializer.Serialize(map)); + return new FileInfo(path).Length; +} diff --git a/docs/api/MicroEngine.Tiled.TiledAssets.md b/docs/api/MicroEngine.Tiled.TiledAssets.md new file mode 100644 index 00000000..5dbc5b0d --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledAssets.md @@ -0,0 +1,99 @@ +# Class TiledAssets + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +The tiled map asset kind: a Tiled map (.tmx or .tmj) with the tilesets (.tsx, +.tsj) and templates (.tx, .tj) it references, read through the store's content source so it +loads from folders, archives and browser packages alike. The asset is data ([TiledMap](MicroEngine.Tiled.TiledMap.md)); its +tile images become textures with [TiledSprites](MicroEngine.Tiled.TiledSprites.md). + +```csharp +public static class TiledAssets +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledAssets](MicroEngine.Tiled.TiledAssets.md) + +## Fields + +### Kind + +The asset kind. + +```csharp +public const string Kind = "tiled map" +``` + +#### Field Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +## Properties + +### Loader + +The loader of the tiled map kind (extensions .tmx and .tmj). + +```csharp +public static IAssetLoader Loader { get; } +``` + +#### Property Value + + [IAssetLoader](MicroEngine.Content.IAssetLoader.md)<[TiledMap](MicroEngine.Tiled.TiledMap.md)\> + +## Methods + +### AddTiledMaps\(AssetLoaderRegistry\) + +Registers the tiled map kind on loaders. + +```csharp +public static AssetLoaderRegistry AddTiledMaps(this AssetLoaderRegistry loaders) +``` + +#### Parameters + +`loaders` [AssetLoaderRegistry](MicroEngine.Content.AssetLoaderRegistry.md) + +#### Returns + + [AssetLoaderRegistry](MicroEngine.Content.AssetLoaderRegistry.md) + +loaders, for chaining. + +### TiledMap\(AssetCache, string\) + +The map at path, loaded once per scope like any asset (the kind is registered on first use). + +```csharp +public static TiledMap TiledMap(this AssetCache assets, string path) +``` + +#### Parameters + +`assets` [AssetCache](MicroEngine.Core.AssetCache.md) + +The game's assets (Game.Assets) or one of its scopes. + +`path` [string](https://learn.microsoft.com/dotnet/api/system.string) + +The map's content path. + +#### Returns + + [TiledMap](MicroEngine.Tiled.TiledMap.md) + +#### Exceptions + + [AssetLoadException](MicroEngine.Core.AssetLoadException.md) + +The map, or a file it references, is missing or not a map Tiled saves; or the map is not orthogonal. + +
diff --git a/docs/api/MicroEngine.Tiled.TiledFrame.md b/docs/api/MicroEngine.Tiled.TiledFrame.md new file mode 100644 index 00000000..ccfcde8f --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledFrame.md @@ -0,0 +1,161 @@ +# Struct TiledFrame + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +One frame of a tile animation. + +```csharp +public readonly struct TiledFrame : IEquatable +``` + +#### Implements + +[IEquatable](https://learn.microsoft.com/dotnet/api/system.iequatable\-1) + +## Constructors + +### TiledFrame\(int, float\) + +One frame of a tile animation. + +```csharp +public TiledFrame(int Gid, float Seconds) +``` + +#### Parameters + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +The global id of the tile shown. + +`Seconds` [float](https://learn.microsoft.com/dotnet/api/system.single) + +How long it is shown. + +## Properties + +### Gid + +The global id of the tile shown. + +```csharp +public int Gid { get; init; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Seconds + +How long it is shown. + +```csharp +public float Seconds { get; init; } +``` + +#### Property Value + + [float](https://learn.microsoft.com/dotnet/api/system.single) + +## Methods + +### Deconstruct\(out int, out float\) + +```csharp +public void Deconstruct(out int Gid, out float Seconds) +``` + +#### Parameters + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`Seconds` [float](https://learn.microsoft.com/dotnet/api/system.single) + +### Equals\(object\) + +```csharp +public override bool Equals(object obj) +``` + +#### Parameters + +`obj` [object](https://learn.microsoft.com/dotnet/api/system.object) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### Equals\(TiledFrame\) + +```csharp +public bool Equals(TiledFrame other) +``` + +#### Parameters + +`other` [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### GetHashCode\(\) + +```csharp +public override int GetHashCode() +``` + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### ToString\(\) + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +## Operators + +### operator ==\(TiledFrame, TiledFrame\) + +```csharp +public static bool operator ==(TiledFrame left, TiledFrame right) +``` + +#### Parameters + +`left` [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +`right` [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### operator \!=\(TiledFrame, TiledFrame\) + +```csharp +public static bool operator !=(TiledFrame left, TiledFrame right) +``` + +#### Parameters + +`left` [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +`right` [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledGroupLayer.md b/docs/api/MicroEngine.Tiled.TiledGroupLayer.md new file mode 100644 index 00000000..4eb2e38f --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledGroupLayer.md @@ -0,0 +1,54 @@ +# Class TiledGroupLayer + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A group of layers. + +```csharp +public sealed class TiledGroupLayer : TiledLayer +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledLayer](MicroEngine.Tiled.TiledLayer.md) ← +[TiledGroupLayer](MicroEngine.Tiled.TiledGroupLayer.md) + +#### Inherited Members + +[TiledLayer.ToString\(\)](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_ToString), +[TiledLayer.Id](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Id), +[TiledLayer.Name](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Name), +[TiledLayer.Class](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Class), +[TiledLayer.Visible](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Visible), +[TiledLayer.Opacity](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Opacity), +[TiledLayer.Tint](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Tint), +[TiledLayer.Offset](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Offset), +[TiledLayer.Parallax](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Parallax), +[TiledLayer.Properties](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Properties), +[object.GetType\(\)](https://learn.microsoft.com/dotnet/api/system.object.gettype), +[object.ToString\(\)](https://learn.microsoft.com/dotnet/api/system.object.tostring), +[object.Equals\(object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\)), +[object.Equals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\-system\-object\)), +[object.ReferenceEquals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.referenceequals), +[object.GetHashCode\(\)](https://learn.microsoft.com/dotnet/api/system.object.gethashcode) + +## Properties + +### Layers + +The group's layers, bottom to top. + +```csharp +public IReadOnlyList Layers { get; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledLayer](MicroEngine.Tiled.TiledLayer.md)\> + +
diff --git a/docs/api/MicroEngine.Tiled.TiledImageLayer.md b/docs/api/MicroEngine.Tiled.TiledImageLayer.md new file mode 100644 index 00000000..52cf5f95 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledImageLayer.md @@ -0,0 +1,78 @@ +# Class TiledImageLayer + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A layer showing one image. + +```csharp +public sealed class TiledImageLayer : TiledLayer +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledLayer](MicroEngine.Tiled.TiledLayer.md) ← +[TiledImageLayer](MicroEngine.Tiled.TiledImageLayer.md) + +#### Inherited Members + +[TiledLayer.ToString\(\)](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_ToString), +[TiledLayer.Id](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Id), +[TiledLayer.Name](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Name), +[TiledLayer.Class](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Class), +[TiledLayer.Visible](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Visible), +[TiledLayer.Opacity](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Opacity), +[TiledLayer.Tint](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Tint), +[TiledLayer.Offset](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Offset), +[TiledLayer.Parallax](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Parallax), +[TiledLayer.Properties](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Properties), +[object.GetType\(\)](https://learn.microsoft.com/dotnet/api/system.object.gettype), +[object.ToString\(\)](https://learn.microsoft.com/dotnet/api/system.object.tostring), +[object.Equals\(object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\)), +[object.Equals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\-system\-object\)), +[object.ReferenceEquals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.referenceequals), +[object.GetHashCode\(\)](https://learn.microsoft.com/dotnet/api/system.object.gethashcode) + +## Properties + +### Image + +The image's content path, or null when the layer has none. + +```csharp +public string? Image { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string)? + +### RepeatX + +Whether the image repeats horizontally. + +```csharp +public bool RepeatX { get; } +``` + +#### Property Value + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### RepeatY + +Whether the image repeats vertically. + +```csharp +public bool RepeatY { get; } +``` + +#### Property Value + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledLayer.md b/docs/api/MicroEngine.Tiled.TiledLayer.md new file mode 100644 index 00000000..d156b066 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledLayer.md @@ -0,0 +1,151 @@ +# Class TiledLayer + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A layer of a [TiledMap](MicroEngine.Tiled.TiledMap.md): what every kind of layer has. + +```csharp +public abstract class TiledLayer +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledLayer](MicroEngine.Tiled.TiledLayer.md) + +#### Derived + +[TiledGroupLayer](MicroEngine.Tiled.TiledGroupLayer.md), +[TiledImageLayer](MicroEngine.Tiled.TiledImageLayer.md), +[TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md), +[TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +## Properties + +### Class + +The layer's class; empty when unset. + +```csharp +public string Class { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Id + +The layer's id, unique in the map. + +```csharp +public int Id { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Name + +The layer's name. + +```csharp +public string Name { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Offset + +Where the layer is drawn relative to the map, in pixels, a group's offset included. + +```csharp +public Vector2 Offset { get; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Opacity + +Opacity from 0 to 1, a group's included. + +```csharp +public float Opacity { get; } +``` + +#### Property Value + + [float](https://learn.microsoft.com/dotnet/api/system.single) + +### Parallax + +The parallax factor, a group's included ((1, 1) moves with the map). The engine does not apply it when drawing. + +```csharp +public Vector2 Parallax { get; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Properties + +The layer's custom properties. + +```csharp +public TiledProperties Properties { get; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### Tint + +The tint colour, a group's included (white when unset). + +```csharp +public Color Tint { get; } +``` + +#### Property Value + + [Color](MicroEngine.Color.md) + +### Visible + +Whether it is shown; a hidden group hides its layers. + +```csharp +public bool Visible { get; } +``` + +#### Property Value + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +## Methods + +### ToString\(\) + +The layer's name and type. + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledMap.md b/docs/api/MicroEngine.Tiled.TiledMap.md new file mode 100644 index 00000000..ac0d8a90 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledMap.md @@ -0,0 +1,411 @@ +# Class TiledMap + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A map made in the Tiled editor, read as data: its size, layers, tilesets and objects. Load it with +[TiledMap](MicroEngine.Tiled.TiledAssets.md#MicroEngine_Tiled_TiledAssets_TiledMap_MicroEngine_Core_AssetCache_System_String_); give its tiles textures with [TiledSprites](MicroEngine.Tiled.TiledSprites.md). + +```csharp +public sealed class TiledMap +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledMap](MicroEngine.Tiled.TiledMap.md) + +## Remarks + +Positions are in map pixels: tile (0, 0) has its top-left at (0, 0). An infinite map is cropped to the cells its +layers use, and [Origin](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_Origin) says where that first cell was in Tiled. Only orthogonal maps load. + +## Properties + +### AllLayers + +Every layer, groups included, in drawing order (bottom to top, a group before its layers). + +```csharp +public IEnumerable AllLayers { get; } +``` + +#### Property Value + + [IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1)<[TiledLayer](MicroEngine.Tiled.TiledLayer.md)\> + +### Background + +The background colour set in Tiled, if any. + +```csharp +public Color? Background { get; } +``` + +#### Property Value + + [Color](MicroEngine.Color.md)? + +### Class + +The map's class (Tiled's "Class" field); empty when unset. + +```csharp +public string Class { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Height + +Height in tiles. + +```csharp +public int Height { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Layers + +The top-level layers, bottom to top; groups hold their own ([TiledGroupLayer](MicroEngine.Tiled.TiledGroupLayer.md)). + +```csharp +public IReadOnlyList Layers { get; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledLayer](MicroEngine.Tiled.TiledLayer.md)\> + +### ObjectLayers + +Every object layer in drawing order. + +```csharp +public IEnumerable ObjectLayers { get; } +``` + +#### Property Value + + [IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1)<[TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md)\> + +### Objects + +Every object of every object layer, layer by layer. + +```csharp +public IEnumerable Objects { get; } +``` + +#### Property Value + + [IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1)<[TiledObject](MicroEngine.Tiled.TiledObject.md)\> + +### Origin + +Where tile (0, 0) was in Tiled, in tiles: (0, 0) except for an infinite map, which is cropped to the cells it uses. + +```csharp +public (int X, int Y) Origin { get; } +``` + +#### Property Value + + \([int](https://learn.microsoft.com/dotnet/api/system.int32) [X](https://learn.microsoft.com/dotnet/api/system.valuetuple\-system.int32,system.int32\-.x), [int](https://learn.microsoft.com/dotnet/api/system.int32) [Y](https://learn.microsoft.com/dotnet/api/system.valuetuple\-system.int32,system.int32\-.y)\) + +### ParallaxOrigin + +The point where parallax shifts nothing, in map pixels (Tiled's parallax origin). + +```csharp +public Vector2 ParallaxOrigin { get; init; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Path + +The map's content path. + +```csharp +public string Path { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### PixelHeight + +Height of the map in pixels. + +```csharp +public int PixelHeight { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### PixelWidth + +Width of the map in pixels. + +```csharp +public int PixelWidth { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Properties + +The map's custom properties. + +```csharp +public TiledProperties Properties { get; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### TileHeight + +Height of a grid cell in pixels. + +```csharp +public int TileHeight { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### TileLayers + +Every tile layer in drawing order. + +```csharp +public IEnumerable TileLayers { get; } +``` + +#### Property Value + + [IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1)<[TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md)\> + +### TileWidth + +Width of a grid cell in pixels. + +```csharp +public int TileWidth { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Tilesets + +The tilesets, by increasing [FirstGid](MicroEngine.Tiled.TiledTileset.md#MicroEngine_Tiled_TiledTileset_FirstGid). + +```csharp +public IReadOnlyList Tilesets { get; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledTileset](MicroEngine.Tiled.TiledTileset.md)\> + +### Width + +Width in tiles. + +```csharp +public int Width { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +## Methods + +### CollisionGrid\(TiledTileLayer, string\) + +A collision grid over layer whose flags come from each tile's property: +a bool property set to true is [Solid](MicroEngine.World.TileFlags.md); a text property lists flag names separated by +commas, bars or spaces (solid, oneway, hazard, water, custom1 to +custom4, any case); a whole number is the flags' bits. Tiles without the property have no flags. + +```csharp +public TileGrid CollisionGrid(TiledTileLayer layer, string property) +``` + +#### Parameters + +`layer` [TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +`property` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + [TileGrid](MicroEngine.World.TileGrid.md) + +#### Exceptions + + [NotSupportedException](https://learn.microsoft.com/dotnet/api/system.notsupportedexception) + +The map's cells are not square. + + [InvalidDataException](https://learn.microsoft.com/dotnet/api/system.io.invaliddataexception) + +A tile's property names an unknown flag or has another type; the message names the tile. + +### CollisionGrid\(TiledTileLayer, Func\) + +A collision grid over layer whose flags flagsOf chooses for each tile. + +```csharp +public TileGrid CollisionGrid(TiledTileLayer layer, Func flagsOf) +``` + +#### Parameters + +`layer` [TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +`flagsOf` [Func](https://learn.microsoft.com/dotnet/api/system.func\-2)<[TiledTile](MicroEngine.Tiled.TiledTile.md), [TileFlags](MicroEngine.World.TileFlags.md)\> + +#### Returns + + [TileGrid](MicroEngine.World.TileGrid.md) + +#### Exceptions + + [NotSupportedException](https://learn.microsoft.com/dotnet/api/system.notsupportedexception) + +The map's cells are not square. + +### Layer\(string\) + +The first layer called name of type T, groups included. + +```csharp +public T Layer(string name) where T : TiledLayer +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + T + +#### Type Parameters + +`T` + +#### Exceptions + + [KeyNotFoundException](https://learn.microsoft.com/dotnet/api/system.collections.generic.keynotfoundexception) + +There is none; the message lists the map's layers. + +### Object\(int\) + +The object with id id (what an object property refers to), or null. + +```csharp +public TiledObject? Object(int id) +``` + +#### Parameters + +`id` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Returns + + [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +### ObjectLayer\(string\) + +The object layer called name (see [Layer``1](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_Layer__1_System_String_)). + +```csharp +public TiledObjectLayer ObjectLayer(string name) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + [TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md) + +### ParallaxShift\(TiledLayer, Vector2\) + +How far layer moves from its place for a view centred on viewCenter, as +Tiled shows it: (view centre − [ParallaxOrigin](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_ParallaxOrigin)) × (1 − the layer's parallax factor). Add it to +where the layer is drawn; it is zero for layers that move with the map. + +```csharp +public Vector2 ParallaxShift(TiledLayer layer, Vector2 viewCenter) +``` + +#### Parameters + +`layer` [TiledLayer](MicroEngine.Tiled.TiledLayer.md) + +`viewCenter` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +#### Returns + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Tile\(int\) + +The tile with global id gid, or null when no tileset holds it (0 included). + +```csharp +public TiledTile? Tile(int gid) +``` + +#### Parameters + +`gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Returns + + [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +### TileLayer\(string\) + +The tile layer called name (see [Layer``1](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_Layer__1_System_String_)). + +```csharp +public TiledTileLayer TileLayer(string name) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + [TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledObject.md b/docs/api/MicroEngine.Tiled.TiledObject.md new file mode 100644 index 00000000..33a71eee --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledObject.md @@ -0,0 +1,412 @@ +# Class TiledObject + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +An object placed on an object layer, or a collision shape of a tile, with its data. + +```csharp +public sealed record TiledObject : IEquatable +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledObject](MicroEngine.Tiled.TiledObject.md) + +#### Implements + +[IEquatable](https://learn.microsoft.com/dotnet/api/system.iequatable\-1) + +## Constructors + +### TiledObject\(int, string, string, TiledShape, Vector2, Vector2, float, bool, IReadOnlyList, int, TileOrientation, string?, TiledProperties\) + +An object placed on an object layer, or a collision shape of a tile, with its data. + +```csharp +public TiledObject(int Id, string Name, string Class, TiledShape Shape, Vector2 Position, Vector2 Size, float Rotation, bool Visible, IReadOnlyList Points, int Gid, TileOrientation Orientation, string? Text, TiledProperties Properties) +``` + +#### Parameters + +`Id` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +The object's id, unique in the map (0 for a tile's collision shapes). + +`Name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +Its name; empty when unset. + +`Class` [string](https://learn.microsoft.com/dotnet/api/system.string) + +Its class (Tiled's "Class", formerly "Type"); for a tile object without one, the tile's class. + +`Shape` [TiledShape](MicroEngine.Tiled.TiledShape.md) + +What kind of object it is. + +`Position` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Where it is in map pixels, its layer's offset included: the top-left of its rectangle, the point, or the origin of its points. + +`Size` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Its width and height in pixels (zero for points, polygons and polylines). + +`Rotation` [float](https://learn.microsoft.com/dotnet/api/system.single) + +Clockwise rotation in radians about Position. + +`Visible` [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +Whether Tiled shows it. + +`Points` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2)\> + +The points of a polygon or polyline, relative to Position; empty otherwise. + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +For a tile object, the global id of its tile; 0 otherwise. + +`Orientation` [TileOrientation](MicroEngine.World.TileOrientation.md) + +For a tile object, how its tile is mirrored. + +`Text` [string](https://learn.microsoft.com/dotnet/api/system.string)? + +For a text object, its text; null otherwise. + +`Properties` [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +Its custom properties; a tile object's include its tile's, its own winning. + +## Properties + +### Bounds + +The rectangle the object covers without rotation: its rectangle, the box around its points, or an empty +rectangle at a point. A tile object's rectangle goes up from its position, as Tiled draws it. + +```csharp +public RectF Bounds { get; } +``` + +#### Property Value + + [RectF](MicroEngine.RectF.md) + +### Center + +The centre of [Bounds](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Bounds). + +```csharp +public Vector2 Center { get; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Class + +Its class (Tiled's "Class", formerly "Type"); for a tile object without one, the tile's class. + +```csharp +public string Class { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Gid + +For a tile object, the global id of its tile; 0 otherwise. + +```csharp +public int Gid { get; init; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Id + +The object's id, unique in the map (0 for a tile's collision shapes). + +```csharp +public int Id { get; init; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Name + +Its name; empty when unset. + +```csharp +public string Name { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Orientation + +For a tile object, how its tile is mirrored. + +```csharp +public TileOrientation Orientation { get; init; } +``` + +#### Property Value + + [TileOrientation](MicroEngine.World.TileOrientation.md) + +### Points + +The points of a polygon or polyline, relative to Position; empty otherwise. + +```csharp +public IReadOnlyList Points { get; init; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2)\> + +### Position + +Where it is in map pixels, its layer's offset included: the top-left of its rectangle, the point, or the origin of its points. + +```csharp +public Vector2 Position { get; init; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Properties + +Its custom properties; a tile object's include its tile's, its own winning. + +```csharp +public TiledProperties Properties { get; init; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### Rotation + +Clockwise rotation in radians about Position. + +```csharp +public float Rotation { get; init; } +``` + +#### Property Value + + [float](https://learn.microsoft.com/dotnet/api/system.single) + +### Shape + +What kind of object it is. + +```csharp +public TiledShape Shape { get; init; } +``` + +#### Property Value + + [TiledShape](MicroEngine.Tiled.TiledShape.md) + +### Size + +Its width and height in pixels (zero for points, polygons and polylines). + +```csharp +public Vector2 Size { get; init; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### Text + +For a text object, its text; null otherwise. + +```csharp +public string? Text { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string)? + +### TurnedBounds + +The axis-aligned rectangle around the object as Tiled shows it turned by [Rotation](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Rotation) about +[Position](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Position); [Bounds](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Bounds) when it is not turned. A quarter turn of a box gives its exact box. + +```csharp +public RectF TurnedBounds { get; } +``` + +#### Property Value + + [RectF](MicroEngine.RectF.md) + +### Visible + +Whether Tiled shows it. + +```csharp +public bool Visible { get; init; } +``` + +#### Property Value + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +## Methods + +### $\(\) + +```csharp +public TiledObject $() +``` + +#### Returns + + [TiledObject](MicroEngine.Tiled.TiledObject.md) + +### Deconstruct\(out int, out string, out string, out TiledShape, out Vector2, out Vector2, out float, out bool, out IReadOnlyList, out int, out TileOrientation, out string?, out TiledProperties\) + +```csharp +public void Deconstruct(out int Id, out string Name, out string Class, out TiledShape Shape, out Vector2 Position, out Vector2 Size, out float Rotation, out bool Visible, out IReadOnlyList Points, out int Gid, out TileOrientation Orientation, out string? Text, out TiledProperties Properties) +``` + +#### Parameters + +`Id` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`Name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`Class` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`Shape` [TiledShape](MicroEngine.Tiled.TiledShape.md) + +`Position` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +`Size` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +`Rotation` [float](https://learn.microsoft.com/dotnet/api/system.single) + +`Visible` [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +`Points` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2)\> + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`Orientation` [TileOrientation](MicroEngine.World.TileOrientation.md) + +`Text` [string](https://learn.microsoft.com/dotnet/api/system.string)? + +`Properties` [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### Equals\(object?\) + +```csharp +public override bool Equals(object? obj) +``` + +#### Parameters + +`obj` [object](https://learn.microsoft.com/dotnet/api/system.object)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### Equals\(TiledObject?\) + +```csharp +public bool Equals(TiledObject? other) +``` + +#### Parameters + +`other` [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### GetHashCode\(\) + +```csharp +public override int GetHashCode() +``` + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### ToString\(\) + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +## Operators + +### operator ==\(TiledObject?, TiledObject?\) + +```csharp +public static bool operator ==(TiledObject? left, TiledObject? right) +``` + +#### Parameters + +`left` [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +`right` [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### operator \!=\(TiledObject?, TiledObject?\) + +```csharp +public static bool operator !=(TiledObject? left, TiledObject? right) +``` + +#### Parameters + +`left` [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +`right` [TiledObject](MicroEngine.Tiled.TiledObject.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledObjectLayer.md b/docs/api/MicroEngine.Tiled.TiledObjectLayer.md new file mode 100644 index 00000000..b2866f3c --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledObjectLayer.md @@ -0,0 +1,66 @@ +# Class TiledObjectLayer + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A layer of objects: placements, areas and points with their data. + +```csharp +public sealed class TiledObjectLayer : TiledLayer +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledLayer](MicroEngine.Tiled.TiledLayer.md) ← +[TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md) + +#### Inherited Members + +[TiledLayer.ToString\(\)](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_ToString), +[TiledLayer.Id](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Id), +[TiledLayer.Name](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Name), +[TiledLayer.Class](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Class), +[TiledLayer.Visible](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Visible), +[TiledLayer.Opacity](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Opacity), +[TiledLayer.Tint](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Tint), +[TiledLayer.Offset](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Offset), +[TiledLayer.Parallax](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Parallax), +[TiledLayer.Properties](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Properties), +[object.GetType\(\)](https://learn.microsoft.com/dotnet/api/system.object.gettype), +[object.ToString\(\)](https://learn.microsoft.com/dotnet/api/system.object.tostring), +[object.Equals\(object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\)), +[object.Equals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\-system\-object\)), +[object.ReferenceEquals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.referenceequals), +[object.GetHashCode\(\)](https://learn.microsoft.com/dotnet/api/system.object.gethashcode) + +## Properties + +### Color + +The colour Tiled shows the layer's objects in, if set. + +```csharp +public Color? Color { get; } +``` + +#### Property Value + + [Color](MicroEngine.Color.md)? + +### Objects + +The objects in the order Tiled stores them. + +```csharp +public IReadOnlyList Objects { get; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledObject](MicroEngine.Tiled.TiledObject.md)\> + +
diff --git a/docs/api/MicroEngine.Tiled.TiledProperties.md b/docs/api/MicroEngine.Tiled.TiledProperties.md new file mode 100644 index 00000000..b5250050 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledProperties.md @@ -0,0 +1,285 @@ +# Class TiledProperties + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +The custom properties of a map, layer, tileset, tile or object, read as data: the engine gives no property a +meaning. Each getter returns its fallback when the property is missing, and throws when it exists with another type. + +```csharp +public sealed class TiledProperties : IReadOnlyCollection, IEnumerable, IEnumerable +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +#### Implements + +[IReadOnlyCollection](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlycollection\-1), +[IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1), +[IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.ienumerable) + +## Constructors + +### TiledProperties\(IEnumerable\) + +Creates a set of properties; a name given twice keeps its last value. + +```csharp +public TiledProperties(IEnumerable properties) +``` + +#### Parameters + +`properties` [IEnumerable](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerable\-1)<[TiledProperty](MicroEngine.Tiled.TiledProperty.md)\> + +## Properties + +### Count + +How many properties there are. + +```csharp +public int Count { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Empty + +No properties. + +```csharp +public static TiledProperties Empty { get; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### this\[string\] + +The property called name. + +```csharp +public TiledProperty this[string name] { get; } +``` + +#### Property Value + + [TiledProperty](MicroEngine.Tiled.TiledProperty.md) + +#### Exceptions + + [KeyNotFoundException](https://learn.microsoft.com/dotnet/api/system.collections.generic.keynotfoundexception) + +There is none; the message lists those there are. + +## Methods + +### Bool\(string, bool\) + +A true-or-false property. + +```csharp +public bool Bool(string name, bool fallback = false) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`fallback` [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### Class\(string\) + +A class property's members ([Empty](MicroEngine.Tiled.TiledProperties.md#MicroEngine_Tiled_TiledProperties_Empty) when it is missing). + +```csharp +public TiledProperties Class(string name) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### Color\(string, Color\) + +A colour property. + +```csharp +public Color Color(string name, Color fallback = default) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`fallback` [Color](MicroEngine.Color.md) + +#### Returns + + [Color](MicroEngine.Color.md) + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### Float\(string, float\) + +A number property; a whole-number property converts. + +```csharp +public float Float(string name, float fallback = 0) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`fallback` [float](https://learn.microsoft.com/dotnet/api/system.single) + +#### Returns + + [float](https://learn.microsoft.com/dotnet/api/system.single) + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### GetEnumerator\(\) + +```csharp +public IEnumerator GetEnumerator() +``` + +#### Returns + + [IEnumerator](https://learn.microsoft.com/dotnet/api/system.collections.generic.ienumerator\-1)<[TiledProperty](MicroEngine.Tiled.TiledProperty.md)\> + +### Has\(string\) + +Whether a property called name exists. + +```csharp +public bool Has(string name) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### Int\(string, int\) + +A whole-number or object property. + +```csharp +public int Int(string name, int fallback = 0) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`fallback` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### String\(string, string?\) + +A text, enum or file property. + +```csharp +public string? String(string name, string? fallback = null) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`fallback` [string](https://learn.microsoft.com/dotnet/api/system.string)? + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string)? + +#### Exceptions + + [InvalidCastException](https://learn.microsoft.com/dotnet/api/system.invalidcastexception) + +The property holds another type. + +### ToString\(\) + +The properties as "name=value" pairs, for diagnostics. + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### TryGet\(string, out TiledProperty?\) + +The property called name, if it exists. + +```csharp +public bool TryGet(string name, out TiledProperty? property) +``` + +#### Parameters + +`name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`property` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledProperty.md b/docs/api/MicroEngine.Tiled.TiledProperty.md new file mode 100644 index 00000000..fb1efaca --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledProperty.md @@ -0,0 +1,212 @@ +# Class TiledProperty + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +One custom property: its name, type and value (see [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) for the value's type). + +```csharp +public sealed record TiledProperty : IEquatable +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledProperty](MicroEngine.Tiled.TiledProperty.md) + +#### Implements + +[IEquatable](https://learn.microsoft.com/dotnet/api/system.iequatable\-1) + +## Constructors + +### TiledProperty\(string, TiledPropertyType, object, string?\) + +One custom property: its name, type and value (see [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) for the value's type). + +```csharp +public TiledProperty(string Name, TiledPropertyType Type, object Value, string? CustomType = null) +``` + +#### Parameters + +`Name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +The property's name. + +`Type` [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) + +The kind of value. + +`Value` [object](https://learn.microsoft.com/dotnet/api/system.object) + +The value. + +`CustomType` [string](https://learn.microsoft.com/dotnet/api/system.string)? + +The name of the custom enum or class, for [Enum](MicroEngine.Tiled.TiledPropertyType.md) and [Class](MicroEngine.Tiled.TiledPropertyType.md); otherwise null. + +## Properties + +### CustomType + +The name of the custom enum or class, for [Enum](MicroEngine.Tiled.TiledPropertyType.md) and [Class](MicroEngine.Tiled.TiledPropertyType.md); otherwise null. + +```csharp +public string? CustomType { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string)? + +### Name + +The property's name. + +```csharp +public string Name { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Type + +The kind of value. + +```csharp +public TiledPropertyType Type { get; init; } +``` + +#### Property Value + + [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) + +### Value + +The value. + +```csharp +public object Value { get; init; } +``` + +#### Property Value + + [object](https://learn.microsoft.com/dotnet/api/system.object) + +## Methods + +### $\(\) + +```csharp +public TiledProperty $() +``` + +#### Returns + + [TiledProperty](MicroEngine.Tiled.TiledProperty.md) + +### Deconstruct\(out string, out TiledPropertyType, out object, out string?\) + +```csharp +public void Deconstruct(out string Name, out TiledPropertyType Type, out object Value, out string? CustomType) +``` + +#### Parameters + +`Name` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`Type` [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) + +`Value` [object](https://learn.microsoft.com/dotnet/api/system.object) + +`CustomType` [string](https://learn.microsoft.com/dotnet/api/system.string)? + +### Equals\(object?\) + +```csharp +public override bool Equals(object? obj) +``` + +#### Parameters + +`obj` [object](https://learn.microsoft.com/dotnet/api/system.object)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### Equals\(TiledProperty?\) + +```csharp +public bool Equals(TiledProperty? other) +``` + +#### Parameters + +`other` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### GetHashCode\(\) + +```csharp +public override int GetHashCode() +``` + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### ToString\(\) + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +## Operators + +### operator ==\(TiledProperty?, TiledProperty?\) + +```csharp +public static bool operator ==(TiledProperty? left, TiledProperty? right) +``` + +#### Parameters + +`left` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +`right` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### operator \!=\(TiledProperty?, TiledProperty?\) + +```csharp +public static bool operator !=(TiledProperty? left, TiledProperty? right) +``` + +#### Parameters + +`left` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +`right` [TiledProperty](MicroEngine.Tiled.TiledProperty.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledPropertyType.md b/docs/api/MicroEngine.Tiled.TiledPropertyType.md new file mode 100644 index 00000000..812f779f --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledPropertyType.md @@ -0,0 +1,69 @@ +# Enum TiledPropertyType + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +The type of a custom property, as Tiled names it. + +```csharp +public enum TiledPropertyType +``` + +## Fields + +`Bool = 3` + +True or false: the value is a `Boolean`. + + + +`Class = 8` + +A custom class: the value is a [TiledProperties](MicroEngine.Tiled.TiledProperties.md) of its members. + + + +`Color = 4` + +A colour: the value is a [Color](MicroEngine.Color.md). + + + +`Enum = 7` + +A custom enum: the value is a `String` (several flags are joined with commas). + + + +`File = 5` + +A file: the value is its content path (a `String`, relative to the content root). + + + +`Float = 2` + +A number: the value is a `Single`. + + + +`Int = 1` + +A whole number: the value is an `Int32`. + + + +`Object = 6` + +A reference to another object: the value is its id (an `Int32`, 0 for none). + + + +`String = 0` + +Text: the value is a `String`. + +
diff --git a/docs/api/MicroEngine.Tiled.TiledShape.md b/docs/api/MicroEngine.Tiled.TiledShape.md new file mode 100644 index 00000000..faab846c --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledShape.md @@ -0,0 +1,57 @@ +# Enum TiledShape + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +The shape of a [TiledObject](MicroEngine.Tiled.TiledObject.md). + +```csharp +public enum TiledShape +``` + +## Fields + +`Ellipse = 1` + +An ellipse inside the rectangle. + + + +`Point = 2` + +A point: the size is zero. + + + +`Polygon = 3` + +A closed polygon through [Points](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Points). + + + +`Polyline = 4` + +An open line through [Points](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Points). + + + +`Rectangle = 0` + +A rectangle of [Size](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Size) from [Position](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Position). + + + +`Text = 5` + +Text ([Text](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Text)) in the rectangle. + + + +`Tile = 6` + +A tile ([Gid](MicroEngine.Tiled.TiledObject.md#MicroEngine_Tiled_TiledObject_Gid)) stretched to the rectangle. + +
diff --git a/docs/api/MicroEngine.Tiled.TiledSprites.md b/docs/api/MicroEngine.Tiled.TiledSprites.md new file mode 100644 index 00000000..570a63ff --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledSprites.md @@ -0,0 +1,208 @@ +# Class TiledSprites + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +The sprites of a [TiledMap](MicroEngine.Tiled.TiledMap.md)'s tiles: each tileset image loaded as a texture through the game's assets +(shared and released with them), and a draw call for tile layers. + +```csharp +public sealed class TiledSprites +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledSprites](MicroEngine.Tiled.TiledSprites.md) + +## Constructors + +### TiledSprites\(TiledMap, AssetCache, TextureFilter?\) + +Loads every tileset image of map through assets. + +```csharp +public TiledSprites(TiledMap map, AssetCache assets, TextureFilter? filter = null) +``` + +#### Parameters + +`map` [TiledMap](MicroEngine.Tiled.TiledMap.md) + +The map. + +`assets` [AssetCache](MicroEngine.Core.AssetCache.md) + +The game's assets (Game.Assets) or the scope the level's textures belong to. + +`filter` [TextureFilter](MicroEngine.Graphics.TextureFilter.md)? + +The textures' sampling; null for the assets' default (the game's presentation). + +#### Exceptions + + [AssetLoadException](MicroEngine.Core.AssetLoadException.md) + +A tileset image is missing or cannot be decoded. + +## Properties + +### Map + +The map the sprites are for. + +```csharp +public TiledMap Map { get; } +``` + +#### Property Value + + [TiledMap](MicroEngine.Tiled.TiledMap.md) + +### this\[int\] + +The sprite of the tile with global id gid, or null (0, unknown ids and tiles without an image). + +```csharp +public Sprite? this[int gid] { get; } +``` + +#### Property Value + + [Sprite](MicroEngine.Graphics.Sprite.md)? + +## Methods + +### At\(int, double\) + +The sprite of gid seconds into its animation (its own sprite when it does not animate). + +```csharp +public Sprite? At(int gid, double seconds) +``` + +#### Parameters + +`gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +#### Returns + + [Sprite](MicroEngine.Graphics.Sprite.md)? + +### Draw\(IRenderer, TiledTileLayer, Camera2D?, double, Vector2\) + +Draws the cells of layer the camera shows, each tile turned as the map says and animated at +seconds, at the layer's offset, with its opacity and tint. A hidden layer draws nothing; +parallax is not applied. + +```csharp +public void Draw(IRenderer renderer, TiledTileLayer layer, Camera2D? camera = null, double seconds = 0, Vector2 shift = default) +``` + +#### Parameters + +`renderer` [IRenderer](MicroEngine.Graphics.IRenderer.md) + +The renderer; push camera on it first when there is one. + +`layer` [TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +A tile layer of [Map](MicroEngine.Tiled.TiledSprites.md#MicroEngine_Tiled_TiledSprites_Map). + +`camera` [Camera2D](MicroEngine.Graphics.Camera2D.md)? + +The camera, used to skip what it does not show; null draws over the screen area. + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +The time animations are at (for example the scene's elapsed time). + +`shift` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Pixels added to the layer's offset, for example its parallax ([ParallaxShift](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_ParallaxShift_MicroEngine_Tiled_TiledLayer_System_Numerics_Vector2_)). + +### Draw\(IRenderer, TiledObjectLayer, Camera2D?, double, Vector2\) + +Draws the visible tile objects of layer that the camera shows, in the layer's order, with +the layer's opacity and tint. Other objects (rectangles, points, text...) are data and draw nothing. + +```csharp +public void Draw(IRenderer renderer, TiledObjectLayer layer, Camera2D? camera = null, double seconds = 0, Vector2 shift = default) +``` + +#### Parameters + +`renderer` [IRenderer](MicroEngine.Graphics.IRenderer.md) + +The renderer; push camera on it first when there is one. + +`layer` [TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md) + +An object layer of [Map](MicroEngine.Tiled.TiledSprites.md#MicroEngine_Tiled_TiledSprites_Map). + +`camera` [Camera2D](MicroEngine.Graphics.Camera2D.md)? + +The camera, used to skip what it does not show; null draws every object. + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +The time animations are at. + +`shift` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Pixels added to every object's position, for example the layer's parallax. + +### Draw\(IRenderer, TiledObject, double, Vector2, Color?\) + +Draws a tile object as Tiled shows it: its tile stretched to its size, standing on its position (the +bottom-left corner), mirrored as it says and turned about that corner. Draws nothing for other objects. + +```csharp +public void Draw(IRenderer renderer, TiledObject tileObject, double seconds = 0, Vector2 shift = default, Color? tint = null) +``` + +#### Parameters + +`renderer` [IRenderer](MicroEngine.Graphics.IRenderer.md) + +The renderer. + +`tileObject` [TiledObject](MicroEngine.Tiled.TiledObject.md) + +A tile object of [Map](MicroEngine.Tiled.TiledSprites.md#MicroEngine_Tiled_TiledSprites_Map). + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +The time animations are at. + +`shift` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Pixels added to its position. + +`tint` [Color](MicroEngine.Color.md)? + +Colour it is multiplied by; null for white. + +### DrawAll\(IRenderer, Camera2D?, double\) + +Draws every visible layer of the map that has something to draw, bottom to top: tile layers and the tile +objects of object layers, each shifted by its parallax when a camera is given. + +```csharp +public void DrawAll(IRenderer renderer, Camera2D? camera = null, double seconds = 0) +``` + +#### Parameters + +`renderer` [IRenderer](MicroEngine.Graphics.IRenderer.md) + +`camera` [Camera2D](MicroEngine.Graphics.Camera2D.md)? + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledTile.md b/docs/api/MicroEngine.Tiled.TiledTile.md new file mode 100644 index 00000000..dd7b9271 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledTile.md @@ -0,0 +1,330 @@ +# Class TiledTile + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A tile of a tileset: where its pixels are and its data. + +```csharp +public sealed record TiledTile : IEquatable +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledTile](MicroEngine.Tiled.TiledTile.md) + +#### Implements + +[IEquatable](https://learn.microsoft.com/dotnet/api/system.iequatable\-1) + +## Constructors + +### TiledTile\(TiledTileset, int, int, string, RectF, string, TiledProperties, IReadOnlyList, IReadOnlyList\) + +A tile of a tileset: where its pixels are and its data. + +```csharp +public TiledTile(TiledTileset Tileset, int Id, int Gid, string Image, RectF Source, string Class, TiledProperties Properties, IReadOnlyList Animation, IReadOnlyList Shapes) +``` + +#### Parameters + +`Tileset` [TiledTileset](MicroEngine.Tiled.TiledTileset.md) + +The tileset holding it. + +`Id` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +Its id in the tileset. + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +Its global id in the map. + +`Image` [string](https://learn.microsoft.com/dotnet/api/system.string) + +The content path of the image its pixels are in. + +`Source` [RectF](MicroEngine.RectF.md) + +Its rectangle in that image, in pixels. + +`Class` [string](https://learn.microsoft.com/dotnet/api/system.string) + +Its class; empty when unset. + +`Properties` [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +Its custom properties. + +`Animation` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledFrame](MicroEngine.Tiled.TiledFrame.md)\> + +Its animation frames; empty when it does not animate. + +`Shapes` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledObject](MicroEngine.Tiled.TiledObject.md)\> + +Its collision shapes drawn in Tiled's tile collision editor, in tile pixels; empty when none. + +## Properties + +### Animation + +Its animation frames; empty when it does not animate. + +```csharp +public IReadOnlyList Animation { get; init; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledFrame](MicroEngine.Tiled.TiledFrame.md)\> + +### AnimationLength + +The length of one loop of [Animation](MicroEngine.Tiled.TiledTile.md#MicroEngine_Tiled_TiledTile_Animation) in seconds (0 when it does not animate). + +```csharp +public float AnimationLength { get; } +``` + +#### Property Value + + [float](https://learn.microsoft.com/dotnet/api/system.single) + +### Class + +Its class; empty when unset. + +```csharp +public string Class { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Gid + +Its global id in the map. + +```csharp +public int Gid { get; init; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Id + +Its id in the tileset. + +```csharp +public int Id { get; init; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Image + +The content path of the image its pixels are in. + +```csharp +public string Image { get; init; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Properties + +Its custom properties. + +```csharp +public TiledProperties Properties { get; init; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### Shapes + +Its collision shapes drawn in Tiled's tile collision editor, in tile pixels; empty when none. + +```csharp +public IReadOnlyList Shapes { get; init; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledObject](MicroEngine.Tiled.TiledObject.md)\> + +### Source + +Its rectangle in that image, in pixels. + +```csharp +public RectF Source { get; init; } +``` + +#### Property Value + + [RectF](MicroEngine.RectF.md) + +### Tileset + +The tileset holding it. + +```csharp +public TiledTileset Tileset { get; init; } +``` + +#### Property Value + + [TiledTileset](MicroEngine.Tiled.TiledTileset.md) + +## Methods + +### $\(\) + +```csharp +public TiledTile $() +``` + +#### Returns + + [TiledTile](MicroEngine.Tiled.TiledTile.md) + +### Deconstruct\(out TiledTileset, out int, out int, out string, out RectF, out string, out TiledProperties, out IReadOnlyList, out IReadOnlyList\) + +```csharp +public void Deconstruct(out TiledTileset Tileset, out int Id, out int Gid, out string Image, out RectF Source, out string Class, out TiledProperties Properties, out IReadOnlyList Animation, out IReadOnlyList Shapes) +``` + +#### Parameters + +`Tileset` [TiledTileset](MicroEngine.Tiled.TiledTileset.md) + +`Id` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`Gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`Image` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`Source` [RectF](MicroEngine.RectF.md) + +`Class` [string](https://learn.microsoft.com/dotnet/api/system.string) + +`Properties` [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +`Animation` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledFrame](MicroEngine.Tiled.TiledFrame.md)\> + +`Shapes` [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledObject](MicroEngine.Tiled.TiledObject.md)\> + +### Equals\(object?\) + +```csharp +public override bool Equals(object? obj) +``` + +#### Parameters + +`obj` [object](https://learn.microsoft.com/dotnet/api/system.object)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### Equals\(TiledTile?\) + +```csharp +public bool Equals(TiledTile? other) +``` + +#### Parameters + +`other` [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### FrameAt\(double\) + +The global id shown seconds into the animation (looping), or [Gid](MicroEngine.Tiled.TiledTile.md#MicroEngine_Tiled_TiledTile_Gid) when it does not animate. + +```csharp +public int FrameAt(double seconds) +``` + +#### Parameters + +`seconds` [double](https://learn.microsoft.com/dotnet/api/system.double) + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### GetHashCode\(\) + +```csharp +public override int GetHashCode() +``` + +#### Returns + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### ToString\(\) + +```csharp +public override string ToString() +``` + +#### Returns + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +## Operators + +### operator ==\(TiledTile?, TiledTile?\) + +```csharp +public static bool operator ==(TiledTile? left, TiledTile? right) +``` + +#### Parameters + +`left` [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +`right` [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +### operator \!=\(TiledTile?, TiledTile?\) + +```csharp +public static bool operator !=(TiledTile? left, TiledTile? right) +``` + +#### Parameters + +`left` [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +`right` [TiledTile](MicroEngine.Tiled.TiledTile.md)? + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledTileLayer.md b/docs/api/MicroEngine.Tiled.TiledTileLayer.md new file mode 100644 index 00000000..4ac722b5 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledTileLayer.md @@ -0,0 +1,91 @@ +# Class TiledTileLayer + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A layer of tiles: one global tile id per cell (0 for none) and how each cell's tile is turned. + +```csharp +public sealed class TiledTileLayer : TiledLayer +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledLayer](MicroEngine.Tiled.TiledLayer.md) ← +[TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +#### Inherited Members + +[TiledLayer.ToString\(\)](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_ToString), +[TiledLayer.Id](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Id), +[TiledLayer.Name](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Name), +[TiledLayer.Class](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Class), +[TiledLayer.Visible](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Visible), +[TiledLayer.Opacity](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Opacity), +[TiledLayer.Tint](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Tint), +[TiledLayer.Offset](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Offset), +[TiledLayer.Parallax](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Parallax), +[TiledLayer.Properties](MicroEngine.Tiled.TiledLayer.md\#MicroEngine\_Tiled\_TiledLayer\_Properties), +[object.GetType\(\)](https://learn.microsoft.com/dotnet/api/system.object.gettype), +[object.ToString\(\)](https://learn.microsoft.com/dotnet/api/system.object.tostring), +[object.Equals\(object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\)), +[object.Equals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.equals\#system\-object\-equals\(system\-object\-system\-object\)), +[object.ReferenceEquals\(object?, object?\)](https://learn.microsoft.com/dotnet/api/system.object.referenceequals), +[object.GetHashCode\(\)](https://learn.microsoft.com/dotnet/api/system.object.gethashcode) + +## Properties + +### Tiles + +The global tile ids, the size of the map: [Tile](MicroEngine.Tiled.TiledMap.md#MicroEngine_Tiled_TiledMap_Tile_System_Int32_) gives each one's tileset, properties and +animation. Changing a cell here changes what is drawn and collided with. + +```csharp +public TileLayer Tiles { get; } +``` + +#### Property Value + + [TileLayer](MicroEngine.World.TileLayer.md) + +## Methods + +### OrientationAt\(int, int\) + +How the tile at (x, y) is mirrored or turned (none outside the map). + +```csharp +public TileOrientation OrientationAt(int x, int y) +``` + +#### Parameters + +`x` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`y` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Returns + + [TileOrientation](MicroEngine.World.TileOrientation.md) + +### SetOrientation\(int, int, TileOrientation\) + +Sets how the tile at (x, y) is mirrored or turned; ignored outside the map. + +```csharp +public void SetOrientation(int x, int y, TileOrientation orientation) +``` + +#### Parameters + +`x` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`y` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +`orientation` [TileOrientation](MicroEngine.World.TileOrientation.md) + +
diff --git a/docs/api/MicroEngine.Tiled.TiledTileset.md b/docs/api/MicroEngine.Tiled.TiledTileset.md new file mode 100644 index 00000000..3319ac94 --- /dev/null +++ b/docs/api/MicroEngine.Tiled.TiledTileset.md @@ -0,0 +1,160 @@ +# Class TiledTileset + +
+ +Namespace: [MicroEngine.Tiled](MicroEngine.Tiled.md) +Assembly: MicroEngine.Tiled.dll +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +A tileset of a [TiledMap](MicroEngine.Tiled.TiledMap.md): one image cut into tiles, or a collection of images. + +```csharp +public sealed class TiledTileset +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TiledTileset](MicroEngine.Tiled.TiledTileset.md) + +## Properties + +### Class + +The tileset's class; empty when unset. + +```csharp +public string Class { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Columns + +Tiles per row of its image (0 for a collection of images). + +```csharp +public int Columns { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### FirstGid + +The global id of its first tile. + +```csharp +public int FirstGid { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Image + +The content path of its image, or null for a collection of images (each tile has its own). + +```csharp +public string? Image { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string)? + +### Name + +The tileset's name. + +```csharp +public string Name { get; } +``` + +#### Property Value + + [string](https://learn.microsoft.com/dotnet/api/system.string) + +### Properties + +The tileset's custom properties. + +```csharp +public TiledProperties Properties { get; } +``` + +#### Property Value + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +### TileHeight + +Height of its tiles in pixels (the largest, for a collection of images). + +```csharp +public int TileHeight { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### TileOffset + +Pixels every tile is drawn shifted by. + +```csharp +public Vector2 TileOffset { get; } +``` + +#### Property Value + + [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +### TileWidth + +Width of its tiles in pixels (the largest, for a collection of images). + +```csharp +public int TileWidth { get; } +``` + +#### Property Value + + [int](https://learn.microsoft.com/dotnet/api/system.int32) + +### Tiles + +Its tiles by increasing id: every cell of its image, or the images of a collection (whose ids may have gaps). + +```csharp +public IReadOnlyList Tiles { get; } +``` + +#### Property Value + + [IReadOnlyList](https://learn.microsoft.com/dotnet/api/system.collections.generic.ireadonlylist\-1)<[TiledTile](MicroEngine.Tiled.TiledTile.md)\> + +## Methods + +### Contains\(int\) + +Whether gid is one of this tileset's tiles. + +```csharp +public bool Contains(int gid) +``` + +#### Parameters + +`gid` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +#### Returns + + [bool](https://learn.microsoft.com/dotnet/api/system.boolean) + +
diff --git a/docs/api/MicroEngine.Tiled.md b/docs/api/MicroEngine.Tiled.md new file mode 100644 index 00000000..6b25d8dd --- /dev/null +++ b/docs/api/MicroEngine.Tiled.md @@ -0,0 +1,85 @@ +# Namespace MicroEngine.Tiled + +
+ +Maps made in the Tiled editor (optional package): the map loaded through the assets, its tile, object, image and group layers, objects and typed properties, tilesets and tiles, collision grids from a tile property, and the sprites that draw tile layers and tile objects. + +Guide: [Maps from Tiled](../manual/world.md#maps-from-tiled) + +### Classes + + [TiledAssets](MicroEngine.Tiled.TiledAssets.md) + +The tiled map asset kind: a Tiled map (.tmx or .tmj) with the tilesets (.tsx, +.tsj) and templates (.tx, .tj) it references, read through the store's content source so it +loads from folders, archives and browser packages alike. The asset is data ([TiledMap](MicroEngine.Tiled.TiledMap.md)); its +tile images become textures with [TiledSprites](MicroEngine.Tiled.TiledSprites.md). + + [TiledGroupLayer](MicroEngine.Tiled.TiledGroupLayer.md) + +A group of layers. + + [TiledImageLayer](MicroEngine.Tiled.TiledImageLayer.md) + +A layer showing one image. + + [TiledLayer](MicroEngine.Tiled.TiledLayer.md) + +A layer of a [TiledMap](MicroEngine.Tiled.TiledMap.md): what every kind of layer has. + + [TiledMap](MicroEngine.Tiled.TiledMap.md) + +A map made in the Tiled editor, read as data: its size, layers, tilesets and objects. Load it with +[TiledMap](MicroEngine.Tiled.TiledAssets.md#MicroEngine_Tiled_TiledAssets_TiledMap_MicroEngine_Core_AssetCache_System_String_); give its tiles textures with [TiledSprites](MicroEngine.Tiled.TiledSprites.md). + + [TiledObjectLayer](MicroEngine.Tiled.TiledObjectLayer.md) + +A layer of objects: placements, areas and points with their data. + + [TiledObject](MicroEngine.Tiled.TiledObject.md) + +An object placed on an object layer, or a collision shape of a tile, with its data. + + [TiledProperties](MicroEngine.Tiled.TiledProperties.md) + +The custom properties of a map, layer, tileset, tile or object, read as data: the engine gives no property a +meaning. Each getter returns its fallback when the property is missing, and throws when it exists with another type. + + [TiledProperty](MicroEngine.Tiled.TiledProperty.md) + +One custom property: its name, type and value (see [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) for the value's type). + + [TiledSprites](MicroEngine.Tiled.TiledSprites.md) + +The sprites of a [TiledMap](MicroEngine.Tiled.TiledMap.md)'s tiles: each tileset image loaded as a texture through the game's assets +(shared and released with them), and a draw call for tile layers. + + [TiledTileLayer](MicroEngine.Tiled.TiledTileLayer.md) + +A layer of tiles: one global tile id per cell (0 for none) and how each cell's tile is turned. + + [TiledTile](MicroEngine.Tiled.TiledTile.md) + +A tile of a tileset: where its pixels are and its data. + + [TiledTileset](MicroEngine.Tiled.TiledTileset.md) + +A tileset of a [TiledMap](MicroEngine.Tiled.TiledMap.md): one image cut into tiles, or a collection of images. + +### Structs + + [TiledFrame](MicroEngine.Tiled.TiledFrame.md) + +One frame of a tile animation. + +### Enums + + [TiledPropertyType](MicroEngine.Tiled.TiledPropertyType.md) + +The type of a custom property, as Tiled names it. + + [TiledShape](MicroEngine.Tiled.TiledShape.md) + +The shape of a [TiledObject](MicroEngine.Tiled.TiledObject.md). + +
diff --git a/docs/api/MicroEngine.World.TileOrientation.md b/docs/api/MicroEngine.World.TileOrientation.md new file mode 100644 index 00000000..214f5a7a --- /dev/null +++ b/docs/api/MicroEngine.World.TileOrientation.md @@ -0,0 +1,47 @@ +# Enum TileOrientation + +
+ +Namespace: [MicroEngine.World](MicroEngine.World.md) +Assembly: MicroEngine.dll +Guide: [Tile maps and collision](../manual/world.md) + +How one cell's tile is mirrored or turned, as map editors store it (Tiled's flip bits). The diagonal flip (x and y +swapped) applies first, then the horizontal and the vertical flips, so the eight combinations cover every quarter +turn with or without a mirror: Diagonal | FlipX is a quarter turn clockwise, Diagonal | FlipY a +quarter turn anticlockwise, FlipX | FlipY a half turn. + +```csharp +[Flags] +public enum TileOrientation : byte +``` + +#### Extension Methods + +[TileOrientations.ToDrawOptions\(TileOrientation, Vector2\)](MicroEngine.World.TileOrientations.md\#MicroEngine\_World\_TileOrientations\_ToDrawOptions\_MicroEngine\_World\_TileOrientation\_System\_Numerics\_Vector2\_) + +## Fields + +`Diagonal = 4` + +Mirrored across the top-left to bottom-right diagonal (x and y swapped), before the other flips. + + + +`FlipX = 1` + +Mirrored left to right. + + + +`FlipY = 2` + +Mirrored top to bottom. + + + +`None = 0` + +Drawn as it is in the tileset. + +
diff --git a/docs/api/MicroEngine.World.TileOrientations.md b/docs/api/MicroEngine.World.TileOrientations.md new file mode 100644 index 00000000..ba0781a4 --- /dev/null +++ b/docs/api/MicroEngine.World.TileOrientations.md @@ -0,0 +1,42 @@ +# Class TileOrientations + +
+ +Namespace: [MicroEngine.World](MicroEngine.World.md) +Assembly: MicroEngine.dll +Guide: [Tile maps and collision](../manual/world.md) + +Turns a [TileOrientation](MicroEngine.World.TileOrientation.md) into drawing options. + +```csharp +public static class TileOrientations +``` + +#### Inheritance + +[object](https://learn.microsoft.com/dotnet/api/system.object) ← +[TileOrientations](MicroEngine.World.TileOrientations.md) + +## Methods + +### ToDrawOptions\(TileOrientation, Vector2\) + +The flips and rotation that draw a tile of size pixels in orientation, +turning about the tile's centre so it stays in its cell. Pass the result to [Draw](MicroEngine.Graphics.IRenderer.md#MicroEngine_Graphics_IRenderer_Draw_MicroEngine_Graphics_Texture_MicroEngine_RectF_System_Numerics_Vector2_MicroEngine_Color_MicroEngine_Graphics_DrawOptions__) with +the tile's top-left as the position. + +```csharp +public static DrawOptions ToDrawOptions(this TileOrientation orientation, Vector2 size) +``` + +#### Parameters + +`orientation` [TileOrientation](MicroEngine.World.TileOrientation.md) + +`size` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +#### Returns + + [DrawOptions](MicroEngine.Graphics.DrawOptions.md) + +
diff --git a/docs/api/MicroEngine.World.TileRenderer.md b/docs/api/MicroEngine.World.TileRenderer.md index 0b1cdc1c..11def465 100644 --- a/docs/api/MicroEngine.World.TileRenderer.md +++ b/docs/api/MicroEngine.World.TileRenderer.md @@ -49,4 +49,51 @@ The camera, used only to skip tiles outside its view; null draws the tiles over Returns the sprite for a tile id at tile (x, y), or null to draw nothing. +### Draw\(IRenderer, TileLayer, int, Camera2D?, Func, Func?, Vector2, Color?, float\) + +Draws the non-zero tiles that can be seen, each at its tile position minus its sprite's origin, mirrored or +turned as orientationAt says. + +```csharp +public static void Draw(IRenderer renderer, TileLayer layer, int tileSize, Camera2D? camera, Func spriteFor, Func? orientationAt, Vector2 offset = default, Color? tint = null, float margin = 0) +``` + +#### Parameters + +`renderer` [IRenderer](MicroEngine.Graphics.IRenderer.md) + +The renderer; push camera on it first when there is one. + +`layer` [TileLayer](MicroEngine.World.TileLayer.md) + +The tile ids; 0 is never drawn. + +`tileSize` [int](https://learn.microsoft.com/dotnet/api/system.int32) + +Tile size in pixels. + +`camera` [Camera2D](MicroEngine.Graphics.Camera2D.md)? + +The camera, used only to skip tiles outside what it shows (zoom and rotation included); null draws the tiles over the screen area. + +`spriteFor` [Func](https://learn.microsoft.com/dotnet/api/system.func\-4)<[int](https://learn.microsoft.com/dotnet/api/system.int32), [int](https://learn.microsoft.com/dotnet/api/system.int32), [int](https://learn.microsoft.com/dotnet/api/system.int32), [Sprite](MicroEngine.Graphics.Sprite.md)?\> + +Returns the sprite for a tile id at tile (x, y), or null to draw nothing. + +`orientationAt` [Func](https://learn.microsoft.com/dotnet/api/system.func\-3)<[int](https://learn.microsoft.com/dotnet/api/system.int32), [int](https://learn.microsoft.com/dotnet/api/system.int32), [TileOrientation](MicroEngine.World.TileOrientation.md)\>? + +The orientation of the tile at (x, y); null draws every tile as it is. A turned tile turns about its sprite's centre. + +`offset` [Vector2](https://learn.microsoft.com/dotnet/api/system.numerics.vector2) + +Where tile (0, 0) is drawn, in world pixels (the layer's offset). + +`tint` [Color](MicroEngine.Color.md)? + +Colour every tile is multiplied by (its alpha is the layer's opacity); null for white. + +`margin` [float](https://learn.microsoft.com/dotnet/api/system.single) + +Extra pixels around the view whose tiles are drawn too, for sprites larger than a cell. + diff --git a/docs/api/MicroEngine.World.md b/docs/api/MicroEngine.World.md index 799c0212..51fa4553 100644 --- a/docs/api/MicroEngine.World.md +++ b/docs/api/MicroEngine.World.md @@ -28,6 +28,10 @@ blocks). X is resolved before Y, each as a swept test in sub-steps no larger tha against the first solid tile or rectangle in its way, however thin, so it never tunnels. One-way tiles only stop bodies falling onto their top edge. + [TileOrientations](MicroEngine.World.TileOrientations.md) + +Turns a [TileOrientation](MicroEngine.World.TileOrientation.md) into drawing options. + [TileRenderer](MicroEngine.World.TileRenderer.md) Draws the visible part of a layer, asking the game which sprite each tile uses. @@ -51,4 +55,11 @@ Answers collision queries for [TileMotion](MicroEngine.World.TileMotion.md). Collision semantics of a tile. Games may use the Custom bits for their own meaning. + [TileOrientation](MicroEngine.World.TileOrientation.md) + +How one cell's tile is mirrored or turned, as map editors store it (Tiled's flip bits). The diagonal flip (x and y +swapped) applies first, then the horizontal and the vertical flips, so the eight combinations cover every quarter +turn with or without a mirror: Diagonal | FlipX is a quarter turn clockwise, Diagonal | FlipY a +quarter turn anticlockwise, FlipX | FlipY a half turn. + diff --git a/docs/api/MicroEngine.md b/docs/api/MicroEngine.md index 4b9ccaea..3a8f244a 100644 --- a/docs/api/MicroEngine.md +++ b/docs/api/MicroEngine.md @@ -38,6 +38,8 @@ Guide: [Graphics](../manual/rendering.md) [MicroEngine.Sdl](MicroEngine.Sdl.md) + [MicroEngine.Tiled](MicroEngine.Tiled.md) + [MicroEngine.UI](MicroEngine.UI.md) [MicroEngine.World](MicroEngine.World.md) diff --git a/docs/api/README.md b/docs/api/README.md index 6e16a796..bed9c1af 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -29,6 +29,7 @@ with the [tutorial](../tutorial/README.md). | [MicroEngine.Saves](MicroEngine.Saves.md) | Saves as an optional engine module: the module that registers a save store as a service of the engine. | [Persistence](../manual/persistence.md) | | [MicroEngine.Scenes](MicroEngine.Scenes.md) | The scene stack (scenes, transitions, coroutines and tweens) and the scene world: entity ids, transforms with a hierarchy, typed attachments, interpolation for drawing and world clocks. | [Scenes](../manual/scenes.md) | | [MicroEngine.Sdl](MicroEngine.Sdl.md) | The SDL 3 platform: Windows, Linux, macOS, Android, iOS and browsers (WebAssembly), with the browser launcher and the browser page it reads. | [Backends and platforms](../manual/backends.md) | +| [MicroEngine.Tiled](MicroEngine.Tiled.md) | Maps made in the Tiled editor (optional package): the map loaded through the assets, its tile, object, image and group layers, objects and typed properties, tilesets and tiles, collision grids from a tile property, and the sprites that draw tile layers and tile objects. | [Maps from Tiled](../manual/world.md#maps-from-tiled) | | [MicroEngine.UI](MicroEngine.UI.md) | Widgets: labels, buttons, panels, stacks, text fields, dialogue boxes, menus, typewriter text, toasts, themes, focus and the UI input layer. | [UI widgets](../manual/ui.md) | | [MicroEngine.World](MicroEngine.World.md) | Tile layers, collision grids, swept movement against tiles (`TileMotion`), autotiling and drawing tiles. | [Tile maps and collision](../manual/world.md) | | [MicroEngine](MicroEngine.md) | Basic value types: colours, rectangles, maths and easing helpers. | [Graphics](../manual/rendering.md) | diff --git a/docs/decisions/0052-tiled-maps-optional-package.md b/docs/decisions/0052-tiled-maps-optional-package.md new file mode 100644 index 00000000..df089178 --- /dev/null +++ b/docs/decisions/0052-tiled-maps-optional-package.md @@ -0,0 +1,40 @@ +# 0052 — Tiled maps through an optional SDK package over DotTiled + +Status: Accepted (2026-11-13). Amends [0019](0019-sdk-packages.md) (the published package set). Evidence: +[TILE-1](../evaluations/tile-1.md). + +## Context + +Tile levels reach the engine only as text written in C# (`TileLayer.Parse` with a character legend). A developer who +draws levels in a map editor, the usual workflow for platformers, top-down games and metroidvanias, must write a +parser for the editor's format, the mapping from tile ids and flip bits to sprites, the reading of objects and +custom properties, and the bridge to collision before the first level shows. Tiled is the most used 2D map editor and +its formats (TMX in XML, TMJ in JSON, external tilesets and templates) are open. DotTiled 1.0.0 (MIT, managed, no +dependencies) reads all of them. + +## Decision + +- **`MicroEngine.Tiled` is an optional SDK package**, released with the other packages, at the same version, by the + same release workflow and approval (ADR 0022). No other package, the `dotnet new` template or a demo depends on it. +- **It reads Tiled maps through DotTiled** (pinned, listed in `THIRD-PARTY-NOTICES.md`) and the engine's asset + sources: every file a map references (tilesets, templates, images) is read through the asset cache, so maps load + from folders, archives and browser packages alike. +- **Its public API uses engine types only.** Tile layers become `TileLayer` plus per-cell flips, tilesets become + `Sprite`s, objects become plain records with typed properties. No DotTiled type is public, so the parser can be + replaced or forked without breaking a game. +- **Properties are data.** The package never gives a meaning to a class name or a property; collision comes from a + tile property the game names, read into `TileFlags`. +- **No map editor** is built, and maps are loaded whole: streaming parts of one map waits for a measured need. + +## Alternatives considered + +- **An own TMJ reader inside `MicroEngine`**: no dependency, but JSON only, and templates, compressed and infinite + layers and custom classes become ours to write and to follow on every Tiled release. +- **A documented recipe only**: every game would still write the importer. +- **LDtk in the same step**: its IntGrid layers and entity fields map onto the same engine types; it waits until the + Tiled importer proves them. + +## Consequences + +- The release publishes one more package, and its check builds a game against it from the packed feed. +- A DotTiled defect is ours to work around or fix upstream; the pin makes any change deliberate. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2052b6a2..046b44a1 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -52,6 +52,7 @@ maintainer's archive, not in the public history. | [0049](0049-release-packages-only-in-releases.md) | Release web packages built only by releases, on Linux, keyed by each game's project graph; macOS tests recorded as evidence (amends 0013) | Accepted | | [0050](0050-ui-primitives-not-components.md) | The UI offers primitives (pointer events, clipping and scrolling, grid, positioned placement, wrapping labels, 9-slice, selection and clipboard, optional shaping); genre components (dialogue box, selection list, toasts, achievement views) become examples (amends 0044, 0006) | Accepted | | [0051](0051-box2d-optional-package.md) | Box2D published as an optional SDK package with declared platform levels; the tile backend and `TileMotion` stay the default (amends 0031, 0019) | Accepted | +| [0052](0052-tiled-maps-optional-package.md) | Tiled maps through an optional `MicroEngine.Tiled` package over DotTiled, with a public API in engine types only (amends 0019) | Accepted | Each number is used once. The numbers missing from the table: diff --git a/docs/evaluations/tile-1.md b/docs/evaluations/tile-1.md new file mode 100644 index 00000000..08683920 --- /dev/null +++ b/docs/evaluations/tile-1.md @@ -0,0 +1,126 @@ +# TILE-1: maps from external editors and large 2D maps + +Issue NastMz/MicroEngine#121. Record: [`odd/tasks/tile-1.md`](../../odd/tasks/tile-1.md). Date: 2026-11-13. +The question is how a developer outside this repository gets levels drawn in a map editor into a MicroEngine game, +and whether large maps need anything the engine lacks. The rule is that of every recent evaluation: could an external +developer build a game whose levels come from an editor today with the published SDK? + +## 1. What exists today + +- **Tile data.** `TileLayer` (a flat `int[]` of ids), `TileGrid` (ids to `TileFlags`), `ICollisionGrid` and + `Autotile` live in `MicroEngine.World` (`src/MicroEngine.Physics.Tiles/TileMap.cs`) and ship inside the + `MicroEngine` package. Square tiles only, one size per grid. The only input format is ASCII rows plus a + character legend (`TileLayer.Parse`), and objects are placed by marker characters (`TileLayer.Find`). +- **Physics.** `TilePhysicsWorld2D.AddTiles(body, grid)` and `TileMotion` read any `ICollisionGrid`; a map's + origin of data does not matter to them. +- **Drawing.** `TileRenderer.Draw(renderer, layer, tileSize, camera, spriteFor)` draws the cells in view, one + `IRenderer.Draw` per cell. It culls with `Camera2D.View` (position and view size), not `VisibleBounds`, so a + camera zoomed out below 1 leaves the outer tiles undrawn (`src/MicroEngine/World/TileRenderer.cs:18`). The + manual says it "touches only visible cells", which is true only at zoom 1 without rotation. It cannot draw a + flipped or rotated tile, although `DrawOptions` has `FlipX`, `FlipY` and `Rotation`. +- **Content.** A game adds a file type with `Assets.Loaders.Add(new AssetLoader(kind, ctx => ..., extensions))`; + `AssetLoadContext.ReadBytes(path)` reads files the asset references, as the glTF loader does for its buffers + (`src/MicroEngine.Graphics3D/MeshAssets.cs:25`). Loading is synchronous on every target, browser included + (ADR 0034). +- **Documentation.** `docs/manual/world.md` and `docs/manual/rendering.md` state there is no importer for map + editors and tell the reader to parse the files themselves. + +**Answer to the rule today:** an external developer can build tile games whose levels are typed as text in C#. A +developer who draws levels in Tiled or LDtk (the usual workflow for a platformer, a top-down RPG or a metroidvania) +must write a parser for the editor's format, the mapping from global tile ids and flip bits to sprites, the reading of +object layers and custom properties, and the bridge to `TileGrid`. That is a few hundred lines per game before the +first level shows, and nothing in the SDK helps. The gap is real. + +## 2. Formats and parsers + +| Format | Editor | What it stores | .NET readers | +| --- | --- | --- | --- | +| TMX (XML), TMJ (JSON), external tilesets TSX/TSJ, templates TX/TJ | Tiled (GPL editor, open format, the most used 2D map editor) | Tile layers (finite or infinite in chunks, ids with flip and rotation bits, optional base64 + gzip/zlib/zstd), object layers (rectangles, points, ellipses, polygons, tile objects), image layers, groups, typed custom properties and custom classes on everything, tile animations, Wang sets | **DotTiled** 1.0.0 (MIT, Oct 2025, last push Apr 2026, net8.0, no dependencies, TMX + TMJ + TSX + templates + infinite maps + custom types); TiledCS (MIT, unmaintained since 2022, TMX only); TiledSharp (Apache, unmaintained) | +| LDtk JSON (`.ldtk`, per-level `.ldtkl`) | LDtk (MIT, by the Dead Cells level designer) | Levels in a world, IntGrid layers (ids meant for collision), auto-layers, tile layers, entities with typed fields | Loaders tied to MonoGame's content pipeline (LDtkMonogame, Monogame-LDtk-Importer); code generated from the official JSON schema with QuickType | + +**DotTiled in detail.** The public entry point is `Loader(IResourceReader, IResourceCache, customTypes)`; +`IResourceReader` is one method, `string Read(string path)`, and every referenced tileset or template is resolved +through it relative to the map's folder. That maps one to one onto `AssetLoadContext.ReadBytes`, so maps load +from any content source (folder, zip, browser package) without touching the file system. Parsing uses `XmlReader` +and `System.Text.Json` DOM types; reflection appears only in the optional `CustomClassDefinition.FromClass` helpers, +which an engine adapter does not need to call. The package is not marked `IsAotCompatible`, so the AOT claim must be +checked by publishing a consumer with NativeAOT, not assumed. + +**Why not write our own reader.** A TMJ-only reader of tile and object layers is about 500 lines over `JsonDocument` +and has no dependency. Covering what Tiled users actually save (TMX by default, external tilesets, templates, +compressed and infinite layers, custom classes) is several thousand lines and follows every Tiled release. DotTiled +already carries that work under MIT. The risk is a single maintainer; the mitigation is that no DotTiled type +appears in the engine's public API, so the parser can be replaced or forked without breaking a game. + +**Why Tiled first.** Tiled is the format most tutorials, asset packs and engines support (the Warped City packs that +Neon Rift uses ship Tiled maps), and its object layers and typed properties are the generic "placements plus data" +the issue asks for. LDtk's IntGrid and entity fields map onto the same engine types (§4), so a second importer can +reuse them later; doing both now doubles the surface before the first one is proven. + +## 3. Large maps + +- **Memory.** A tile layer costs 4 bytes per cell: a 1000 × 1000 layer is 4 MB, three layers 12 MB. Tiled's + infinite maps can be flattened to their used bounds on load. +- **Drawing.** With culling, drawing costs what the screen shows (a 320 × 180 view at 16 px is about 260 cells per + layer), independent of map size. The one defect is the zoom case in §1. +- **Collision.** `TileMotion` and `TilePhysicsWorld2D` read only the cells a box sweeps; map size does not enter. +- **Incremental loading.** Content loads synchronously and a map file of a few MB parses in milliseconds; streaming + regions of one map has no measured need today. A game that wants rooms or regions loads one map file per area, as + Tiled's world files (`.world`) and LDtk's per-level files already encourage. + +This section was written as arithmetic over the code paths named; the implementation measured it with +`benchmarks/MapBench` (Windows x64, .NET 10.0.15, median of 5 runs, 2026-11-13) on a 1000 × 1000 map of 16-pixel tiles +with three base64 + zlib layers (a 605 KB file): + +| Measurement | Median | Allocated | +| --- | ---: | ---: | +| Load the map through the asset cache (parse, decompress, build layers) | 80.35 ms | 45 MB | +| Draw one frame, 320 × 180 camera at zoom 1 (351 tiles) | 82.2 µs | 1 KB | +| Draw one frame at zoom 0.5 (1,044 tiles) | 244 µs | 1 KB | + +The layers take 14.3 MB (an id and an orientation per cell). Drawing follows what the camera shows, not the map's +size, so streaming parts of one map stays unjustified; the load allocates about three times the map's final size in +the parser's intermediate arrays, a one-off cost per level. + +## 4. Options + +1. **Optional package `MicroEngine.Tiled` over DotTiled.** Managed only (no natives), released with the others like + `MicroEngine.Physics.Box2D` (ADR 0051), so a game without maps carries no extra dependency. Public API in engine + terms: + - `TiledMap` loaded through Content (`.tmx`, `.tmj`): size, tile size, background colour, layers in order. + - Tile layers as `TileLayer` (local ids) plus per-cell flip and rotation, and the `Sprite` of each id resolved + from the tilesets' images through the asset cache (atlas regions, margins, spacing, image-collection tilesets). + - Tile animations as frame lists the game can play with existing sprite animation. + - Object layers as plain records: id, name, class, position, size, rotation, shape (rectangle, point, ellipse, + polygon, polyline, tile), typed properties (bool, int, float, string, colour, file, object reference, class). + - Properties exposed as data only: the engine never interprets `"enemy"` or `"door"`. A helper builds a + `TileGrid` from a tile property the game names (for example `collision = solid | oneway`) into `TileFlags`. + - A layer draw call over `TileRenderer` that honours flips. +2. **Our own TMJ reader inside the `MicroEngine` package.** No dependency, AOT certain, but JSON only, no templates, + no compressed or infinite layers unless we write them; every Tiled feature becomes ours to maintain. +3. **Recipe only.** Document how to read a TMJ with `System.Text.Json` into a `TileLayer`. The external developer + still writes the importer; the gap stays open. + +Independent of the option, two engine fixes the mapping found: `TileRenderer` culls with `VisibleBounds` (zoom and +rotation), and it can draw a cell flipped or rotated. + +## 5. Recommendation + +Option 1. It closes the gap for the editor most developers already use, keeps game rules out of the engine (objects +and properties are data, collision is opt-in through a property the game names), keeps the parser replaceable, and +follows the optional-package path that ADR 0051 already set up. Option 2 costs more to reach the same coverage and +leaves TMX out. Option 3 does not close the gap. LDtk becomes a follow-up issue once the engine-side types prove +themselves with Tiled. No map editor is built (S4). + +## 6. Implementation if the owner approves option 1 + +1. Engine fixes with tests: `TileRenderer` culls with `VisibleBounds`; flipped and rotated cells. +2. `src/MicroEngine.Tiled` (references Content, Graphics2D, Physics.Tiles and DotTiled only), its tests over real + TMX and TMJ files covering every layer and object kind, flips, external tilesets and templates from a zip source. +3. Release path: ninth package in `sdk/version.mjs`, packed by `sdk.yml`, the external consumer check, third-party + notice for DotTiled, ADR for the package. +4. Example consuming a real CC0 Tiled map through package references (the Warped City map, already the source of + Neon Rift's art), with collision from tile properties and objects spawning entities; a NativeAOT publish of it + and a browser build as the platform evidence. +5. Benchmark of a large map (§3) and the manual: a "Maps from Tiled" section in `docs/manual/world.md`, the limits + lines updated, release notes under `## Next release`. diff --git a/docs/guide/project-structure.md b/docs/guide/project-structure.md index 7e61e117..522def96 100644 --- a/docs/guide/project-structure.md +++ b/docs/guide/project-structure.md @@ -12,7 +12,7 @@ This page maps the folders and then shows how a game project is organised, using | `src/MicroEngine.Cli/` | The `microengine` tool, with the browser, Android and iOS hosts it packages games with (`Hosts/`) | | `templates/` | The `dotnet new` template | | `games//` | A complete demo game: code, procedural art and audio, optional asset files, input scripts, README | -| `samples/` | `HelloEngine` (the smallest game, [walkthrough](../examples/hello-engine.md)), `GemGarden` (the tutorial), `Examples` (the focused examples), `AssetGallery`, `Slice3D` (the 3D path), `PhysicsSubstitution` (the two physics backends side by side) | +| `samples/` | `HelloEngine` (the smallest game, [walkthrough](../examples/hello-engine.md)), `GemGarden` (the tutorial), `Examples` (the focused examples), `AssetGallery`, `Slice3D` (the 3D path), `PhysicsSubstitution` (the two physics backends side by side), `TiledLevel` (a level saved by the Tiled editor) | | `tests/` | Engine and backend tests (MSTest), each game's rule tests and headless playthroughs, the samples' build checks, suites shared between test projects (`tests/Shared`) | | `web/` | The browser build scripts, the pinned toolchain, a local server and the browser tests ([web/README.md](../../web/README.md)) | | `sdk/` | The documented SDK version and the checks that use the SDK from outside the repository | diff --git a/docs/guide/release-notes.md b/docs/guide/release-notes.md index 5432c0f7..0288e9f3 100644 --- a/docs/guide/release-notes.md +++ b/docs/guide/release-notes.md @@ -6,8 +6,22 @@ releases (`v0.1.0`, `v0.2.0`...) are separate. ## Next release +A ninth package: `MicroEngine.Tiled`, optional, for maps made in the Tiled editor. + +**Added** + +- `MicroEngine.Tiled` loads `.tmx` and `.tmj` maps with their tilesets and templates through the game's assets + (`Assets.TiledMap(path)`): tile layers with each cell's flips, tilesets as sprites (`TiledSprites`), objects and + typed custom properties as data, a collision grid from a tile property the game names, and tile objects and parallax + drawn as Tiled shows them ([Maps from Tiled](../manual/world.md#maps-from-tiled)). The `TiledLevel` sample plays a + level saved by Tiled. +- `TileOrientation`, and a `TileRenderer.Draw` overload that draws each cell mirrored or turned, at an offset, tinted + and with an extra margin for sprites larger than a cell. + **Fixed** +- `TileRenderer` skipped the tiles a zoomed-out or rotated camera shows beyond its unzoomed view: it now culls with + `Camera2D.VisibleBounds`. - `microengine package windows`, `linux` and `macos` stopped with `NETSDK1004` ("assets file not found") for a game whose `Directory.Build.props` moves the restore output when the package's publish properties are set: the tool looked for the .NET runtime pack the build used without those properties. It now reads it with the same properties diff --git a/docs/guide/sdk.md b/docs/guide/sdk.md index 2473a01f..fb0aa33f 100644 --- a/docs/guide/sdk.md +++ b/docs/guide/sdk.md @@ -23,6 +23,7 @@ Alpha means two things: | `MicroEngine.Desktop` | `DesktopLauncher` and the official desktop catalog (`sdl`, `raylib`, `sdl-software`, `headless`) | `MicroEngine.Sdl`, `MicroEngine.Raylib` | | `MicroEngine.Text.Shaping` | optional: `ShapedFont`, text shaped by HarfBuzz for Arabic, Hebrew, Indic and other complex scripts ([Shaped text](../manual/text.md#shaped-text-arabic-hebrew-and-indic-scripts)) | `MicroEngine`, `HarfBuzzSharp` and its native packages | | `MicroEngine.Physics.Box2D` | optional: `Box2DPhysicsWorld2D`, Box2D 3.2 behind the physics contract, for dynamic bodies, rotation, round and polygon shapes and continuous collision, with Box2D's native library for each runtime ([The Box2D backend](../manual/world.md#the-box2d-backend)) | `MicroEngine` | +| `MicroEngine.Tiled` | optional: maps made in the Tiled editor (`.tmx`, `.tmj`, their tilesets and templates) as tile layers, sprites, objects and typed properties, with collision from a tile property ([Maps from Tiled](../manual/world.md#maps-from-tiled)) | `MicroEngine`, `DotTiled` | | `MicroEngine.Cli` | the [`microengine` tool](cli.md), with [`microengine shaders`](cli.md#shaders), and the browser, Android and iOS hosts it builds with | — | | `MicroEngine.Templates` | `dotnet new microengine-game` | — | diff --git a/docs/manual/assets.md b/docs/manual/assets.md index b52fbe63..d5f3ac91 100644 --- a/docs/manual/assets.md +++ b/docs/manual/assets.md @@ -293,6 +293,7 @@ font in `Content/`. Examples: [Neon Rift](../../games/NeonRift/CREDITS.md), `IAssetImporter` only reserves the place of that step. - Content sources are read whole and synchronously: a zip archive decompresses a file when it is opened, and a browser page still copies the package's files into memory before the game starts. -- Atlases with rotated frames, Aseprite slices, libGDX `.atlas`, Spine and Tiled files are not read. +- Atlases with rotated frames, Aseprite slices, libGDX `.atlas` and Spine files are not read. Tiled maps are read by the + optional `MicroEngine.Tiled` package ([Maps from Tiled](world.md#maps-from-tiled)). - Animated GIFs load their first frame only; there is no video. - Fonts: see [Text](text.md#limits). diff --git a/docs/manual/rendering.md b/docs/manual/rendering.md index 49747176..f12fe812 100644 --- a/docs/manual/rendering.md +++ b/docs/manual/rendering.md @@ -495,7 +495,7 @@ a 3D renderer: - No shaders, render targets or post-processing are exposed through `IRenderer`. - No mipmaps or compressed GPU texture formats; textures are 32-bit RGBA. - No sprite batching control beyond packing art into atlases; draw order is call order. -- Tile maps come from code (ASCII rows); there is no map editor or Tiled importer. +- Tile maps come from code (ASCII rows) or from the Tiled editor through the optional `MicroEngine.Tiled` package ([Maps from Tiled](world.md#maps-from-tiled)); there is no map editor. - On Windows with NVIDIA's driver 32.0.16.1088, a process that creates and destroys many `sdl-gpu` devices on Vulkan in quick succession can, rarely, be ended by a fault inside the driver's own background thread. A game keeps one device for its whole run and is not affected; tools that open devices repeatedly can use Direct3D 12 on such machines. diff --git a/docs/manual/world.md b/docs/manual/world.md index fd7fb6ca..c210cb47 100644 --- a/docs/manual/world.md +++ b/docs/manual/world.md @@ -167,10 +167,101 @@ The tutorial's player and slimes are plain classes too. MicroEngine has no entit ## Drawing tiles -`TileRenderer.Draw(r, layer, tileSize, camera, spriteFor)` draws the cells the camera sees (with a one-tile margin), -calling `spriteFor(id, x, y)` for each non-empty cell; return `null` to skip one. Call it between `PushCamera` and -`PopCamera`. `Autotile.Mask4(layer, x, y, same)` returns a 4-bit mask of matching neighbours (1 up, 2 right, 4 down, -8 left), the usual way to choose a wall's edge or corner sprite. +`TileRenderer.Draw(r, layer, tileSize, camera, spriteFor)` draws the cells the camera shows, zoom and rotation +included, with a one-tile margin, calling `spriteFor(id, x, y)` for each non-empty cell; return `null` to skip one. +Call it between `PushCamera` and `PopCamera`. Its longer overload also takes each cell's `TileOrientation` (mirrored +left to right, top to bottom, or across the diagonal, as map editors store it; the eight combinations cover every +quarter turn), where the layer is drawn, a tint and an extra margin for sprites larger than a cell. +`Autotile.Mask4(layer, x, y, same)` returns a 4-bit mask of matching neighbours (1 up, 2 right, 4 down, 8 left), the +usual way to choose a wall's edge or corner sprite. + +## Maps from Tiled + +The optional `MicroEngine.Tiled` package loads maps made in the [Tiled](https://www.mapeditor.org/) editor: `.tmx` and +`.tmj` files with their external tilesets (`.tsx`, `.tsj`) and templates (`.tx`, `.tj`), compressed or not, finite or +infinite. + +```xml + +``` + +`Assets.TiledMap(path)` reads the map and every file it names through the game's assets, so maps load from folders, +archives and browser packages alike, once per scope. The map is data; `new TiledSprites(map, Assets)` turns its +tileset images into textures. Load both in `Load`, as any asset: + + +```csharp +// The map, its tileset and its templates come through the game's assets, like any content file; the tileset's +// images become textures here, so nothing loads during play. +Map = Assets.TiledMap("map/sandbox.tmx"); +Sprites = new TiledSprites(Map, Assets); +``` + +What the map holds: + +- **Tile layers** (`map.TileLayer(name)`) are a `TileLayer` of global tile ids (0 for empty) the size of the map, and + `OrientationAt(x, y)` for each cell. `map.Tile(id)` gives a tile's tileset, class, properties, animation frames and + the collision shapes drawn in Tiled's tile editor. +- **Object layers** (`map.ObjectLayer(name)`, or `map.Objects` for all of them) hold `TiledObject`s: name, class, + shape (rectangle, ellipse, point, polygon, polyline, text, tile), position in map pixels with the layer's offset + included, size, rotation in radians, `Bounds` and `TurnedBounds`. A template's instance has the template's values + and properties, its own winning; a tile object has its tile's class and properties under its own. +- **Properties** of the map, layers, tilesets, tiles and objects are typed: `String`, `Int`, `Float`, `Bool`, `Color`, + `Class` (a custom class's members); a file property is a content path, an object property an object id + (`map.Object(id)`). +- **Layers** keep their visibility, opacity, tint, offset and parallax factor, groups included. + +The engine gives no class or property a meaning: what an object is, is the game's rule. The Tiled sample builds its +level from the objects the designer marked: + + +```csharp +// The map is data: this game decides what its classes and properties mean. +foreach (var o in map.Objects) +{ + if (o.Properties.String("bodyType") is "static" or "dynamic") _solids.Add(o.TurnedBounds); + if (o.Class == "coin") _coins.Add(o); + if (o.Class == "hero") + { + _heroSprite = o; + // The box stands where the sprite's feet are: a tile object's position is its bottom-left corner. + _hero = new RectF(o.Position.X + (o.Size.X - HeroBox.X) / 2, o.Position.Y - HeroBox.Y, HeroBox.X, HeroBox.Y); + } +} +``` + +For a tile map, `map.CollisionGrid(layer, "collision")` builds a `TileGrid` over a tile layer from a tile property +the game names: `true` is solid, a text lists flags (`solid`, `oneway`, `hazard`, `water`, `custom1` to `custom4`), a +whole number is the flags' bits. The grid reads the layer, so changing a cell changes collisions at once. +`CollisionGrid(layer, tile => ...)` chooses the flags in code. + +`TiledSprites.Draw(r, tileLayer, camera, seconds)` draws a tile layer as `TileRenderer` does, with each cell's +orientation, the layer's offset, opacity and tint, and animated tiles at `seconds`. `Draw(r, objectLayer, ...)` draws +an object layer's tile objects as Tiled shows them: stretched to their size, standing on their bottom-left corner, +mirrored and turned about it. `DrawAll(r, camera, seconds)` draws every visible layer in order, each shifted by its +parallax (`map.ParallaxShift(layer, camera.Center)`, which a game can also use itself): + + +```csharp +// Layer by layer, as Tiled shows them, each shifted by its parallax. The "game" layer's objects move, so the +// scene draws them where the game has them instead of where the map placed them. +var map = game.Map; +foreach (var layer in map.ObjectLayers) +{ + var shift = map.ParallaxShift(layer, WorldCamera!.Center); + if (layer.Name != "game") game.Sprites.Draw(r, layer, WorldCamera, Age, shift); + else + { + foreach (var coin in _coins) game.Sprites.Draw(r, coin, Age); + var feet = new Vector2(_hero.Center.X - _heroSprite.Size.X / 2, _hero.Bottom); + game.Sprites.Draw(r, _heroSprite with { Position = feet, Orientation = _facingLeft ? TileOrientation.FlipX : TileOrientation.None }); + } +} +``` + +A 1000 × 1000 map of three compressed layers loads in about 80 ms and takes about 14 MB; drawing costs what the +camera shows (about 350 tiles of 16 pixels in a 320 × 180 view), whatever the map's size. Load one map per area for +worlds larger than that. ## Performance @@ -184,7 +275,8 @@ a sensor looks only at the shapes near it; the results are the same as looking a - Axis-aligned boxes only: no slopes, circles or rotated shapes. `TileMotion` moves one box against rectangles that stand still during the call; a physics world's moving solids push and carry other bodies. - One tile size per grid; no isometric or hexagonal grids. -- No importer for map editors (Tiled, LDtk): read their files yourself into a `TileLayer` if you use one. +- Maps from Tiled load with `MicroEngine.Tiled`, orthogonal ones only. Image layers and text objects are data: the + game draws them. No other map editor's files are read (LDtk: read them yourself into a `TileLayer`). ## Common mistakes @@ -333,5 +425,6 @@ at once. - API: [`TileMotion`](../api/MicroEngine.World.TileMotion.md), [`TileGrid`](../api/MicroEngine.World.TileGrid.md), [`IWorld`](../api/MicroEngine.Scenes.IWorld.md), [`IPhysicsWorld2D`](../api/MicroEngine.Physics.IPhysicsWorld2D.md), - [`TilePhysicsWorld2D`](../api/MicroEngine.Physics.Tiles.TilePhysicsWorld2D.md); `Box2DPhysicsWorld2D` is not in the API + [`TilePhysicsWorld2D`](../api/MicroEngine.Physics.Tiles.TilePhysicsWorld2D.md), + [`TiledMap`](../api/MicroEngine.Tiled.TiledMap.md), [`TiledSprites`](../api/MicroEngine.Tiled.TiledSprites.md); `Box2DPhysicsWorld2D` is not in the API reference until its package is published diff --git a/odd/tasks/tile-1.md b/odd/tasks/tile-1.md new file mode 100644 index 00000000..17610553 --- /dev/null +++ b/odd/tasks/tile-1.md @@ -0,0 +1,57 @@ +# TILE-1: maps from external editors (Tiled) and large 2D maps + +Objective: decide, with evidence, how an external developer gets maps made in an editor into a MicroEngine game, then +implement what the owner approves. Issue NastMz/MicroEngine#121. Branch `docs/tile-1` for the evaluation; +implementation branches follow the approved option. Delivery strategy: `ask-on-risk`. Test runner: +`dotnet test MicroEngine.slnx -c Release`. Evaluation: [`docs/evaluations/tile-1.md`](../../docs/evaluations/tile-1.md). + +## Specs + +- S1 Formats: "Compare importing Tiled or other open formats for tiles, layers, object placements and custom properties + without embedding game-specific behavior in the engine." +- S2 Integration: "Evaluate existing parsers/adapters, atlas/animation references and integration with the Content + system and physics." +- S3 Scale: "Assess larger maps, visible-region culling and incremental loading only where measurable needs exist." +- S4 Scope limit: "Do not build a native map editor by default." +- S5 Done when: "Produce an evidence-based recommendation and, if justified, a small example consuming a real map + through the public SDK." +- S6 Judge by an external developer (owner's rule, as in UI-1 S1 and PHYS-1 S10): could someone outside the repository + build a game whose levels are drawn in a map editor today with the published SDK. The deliverable is the + implementation the research justifies (UI-1 S2). +- S7 Owner gate (as PHYS-1 S8): a new package or a new third-party dependency in the SDK needs the owner's explicit + approval. + +## Tasks + +| Task | Specs | Route | Status | Commit / evidence | +| --- | --- | --- | --- | --- | +| T1 Map tile types, renderer, Content loaders, packages, samples | S2 S3 | delegated (mapping trigger: >5 lookups across Physics.Tiles, Content, Graphics2D, samples, sdk) | done | evaluation §1 | +| T2 Survey formats and .NET parsers (Tiled TMX/TMJ, LDtk; DotTiled, TiledCS, LDtk loaders) | S1 S2 | inline | done | evaluation §2 | +| T3 Evaluation `docs/evaluations/tile-1.md` with the recommendation | S1–S6 | inline | done | this branch | +| T4 Owner decision on the option | S7 | owner | done | L3: option 1; ADR 0052 | +| T5 Engine fixes: `TileRenderer` culls with `VisibleBounds`; flipped and rotated cells | S3 | inline | done | `adc0e1b`; RED: zoom 0.5 skipped tile (0,0); `TileRendererTests` 4/4 | +| T6 `MicroEngine.Tiled`: loader, layers, tilesets, objects, properties, collision grid, tests over TMX/TMJ | S1 S2 | inline | done | `fd64fc2`, `70d8115`; `MicroEngine.Tiled.Tests` 25/25 | +| T7 Release path: package list, package checks, notices, architecture test | S7 | inline | done | `2f8dd8b`; version tests 29/29; `dotnet pack` deps MicroEngine + DotTiled [1.0.0] | +| T8 Example consuming a real Tiled map through the public SDK | S5 | inline | done | `7850eea`; `samples/TiledLevel` (Tiled's Sticker Knight map); `TiledLevelTests` 2/2; tour captures inspected | +| T9 Large-map benchmark, manual, API reference, release notes | S3 | inline | done | `benchmarks/MapBench` (evaluation §3); snippets check passes | + +## Log + +- L1 (2026-11-13) Owner: "Sigue trabajando en el siguiente issue". The board's top open item is #121 TILE-1. +- L2 (2026-11-13) Mapping: the only map input is ASCII rows plus a legend (`TileLayer.Parse`); `TileRenderer` culls + with `Camera2D.View`, which ignores zoom and rotation, so a zoomed-out camera loses the outer tiles (defect found in + passing, `src/MicroEngine/World/TileRenderer.cs:18`). DotTiled 1.0.0 (MIT, net8.0, no dependencies, TMX + TMJ + + templates + infinite maps) is the only maintained .NET parser that covers the whole format. Recommendation: an + optional `MicroEngine.Tiled` package over DotTiled whose public API exposes engine types only. Next: owner decision + (T4). +- L3 (2026-11-13) Owner: "Approve option 1". Recorded as ADR 0052. Branch renamed to `feat/tile-1`; T5 starts. +- L4 (2026-11-13) Owner, asked whether to download a map saved by Tiled for the example: "Download Sticker Knight + (Recommended)". Tiled's `examples/sticker-knight/map` (public domain, Ponywolf; commit `8c19461`) is in + `samples/TiledLevel/Content/map`. +- L5 (2026-11-13) Two DotTiled 1.0.0 defects found and worked around inside the package, both pinned by tests: its + loader resolves a template's tileset against the map's folder (the package now resolves each file from its own + folder with DotTiled's readers), and its TMJ reader replaces a template's properties instead of merging them (merged + from the template read). `GetSourceRectangleForLocalTileID` returned empty rectangles for described tiles; cells + are computed from columns, margin and spacing. Not reported upstream (publishing needs the owner's approval). +- L6 (2026-11-13) MapBench: 1000 × 1000 × 3 layers load in 80 ms (45 MB allocated), a frame draws in 82 µs at zoom 1 + and 244 µs at zoom 0.5. Streaming one map stays unjustified (evaluation §3). diff --git a/samples/Directory.Build.targets b/samples/Directory.Build.targets index 15cb80b1..9537462b 100644 --- a/samples/Directory.Build.targets +++ b/samples/Directory.Build.targets @@ -15,6 +15,10 @@ <_MicroEngineShaping Include="@(PackageReference->WithMetadataValue('Identity', 'MicroEngine.Text.Shaping'))" /> + + <_MicroEngineTiled Include="@(PackageReference->WithMetadataValue('Identity', 'MicroEngine.Tiled'))" /> + + <_MicroEngineBox2D Include="@(PackageReference->WithMetadataValue('Identity', 'MicroEngine.Physics.Box2D'))" /> diff --git a/samples/TiledLevel/CREDITS.md b/samples/TiledLevel/CREDITS.md new file mode 100644 index 00000000..2e9e7bd2 --- /dev/null +++ b/samples/TiledLevel/CREDITS.md @@ -0,0 +1,9 @@ +# Tiled Level credits + +The sample's code is MIT (the repository's `LICENSE`). Its files under `Content/map/` come from others: + +| Files | Source | Author | Licence | Changes | +| --- | --- | --- | --- | --- | +| `Content/map/*` (the map `sandbox.tmx`, the tileset `objs.tsx`, its images and `templates/`) | The Sticker Knight example of the Tiled editor ([mapeditor/tiled](https://github.com/mapeditor/tiled/tree/8c19461218a05ced2d9d727b6635e736d8a3867f/examples/sticker-knight/map), commit `8c19461`); originally [Sticker Knight Platformer](https://opengameart.org/content/sticker-knight-platformer) | Ponywolf | Public domain (as Tiled's example README states) | None | + +Licence checked on 2026-11-13. diff --git a/samples/TiledLevel/Content/map/alter.png b/samples/TiledLevel/Content/map/alter.png new file mode 100644 index 00000000..1571c542 Binary files /dev/null and b/samples/TiledLevel/Content/map/alter.png differ diff --git a/samples/TiledLevel/Content/map/backgroundArch.png b/samples/TiledLevel/Content/map/backgroundArch.png new file mode 100644 index 00000000..84ba79da Binary files /dev/null and b/samples/TiledLevel/Content/map/backgroundArch.png differ diff --git a/samples/TiledLevel/Content/map/backgroundMountain.png b/samples/TiledLevel/Content/map/backgroundMountain.png new file mode 100644 index 00000000..3596080b Binary files /dev/null and b/samples/TiledLevel/Content/map/backgroundMountain.png differ diff --git a/samples/TiledLevel/Content/map/backgroundTower.png b/samples/TiledLevel/Content/map/backgroundTower.png new file mode 100644 index 00000000..d602bb21 Binary files /dev/null and b/samples/TiledLevel/Content/map/backgroundTower.png differ diff --git a/samples/TiledLevel/Content/map/backgroundTree.png b/samples/TiledLevel/Content/map/backgroundTree.png new file mode 100644 index 00000000..ccee0f4c Binary files /dev/null and b/samples/TiledLevel/Content/map/backgroundTree.png differ diff --git a/samples/TiledLevel/Content/map/blobBlue.png b/samples/TiledLevel/Content/map/blobBlue.png new file mode 100644 index 00000000..6ed48732 Binary files /dev/null and b/samples/TiledLevel/Content/map/blobBlue.png differ diff --git a/samples/TiledLevel/Content/map/blobGreen.png b/samples/TiledLevel/Content/map/blobGreen.png new file mode 100644 index 00000000..465c93fe Binary files /dev/null and b/samples/TiledLevel/Content/map/blobGreen.png differ diff --git a/samples/TiledLevel/Content/map/blue.png b/samples/TiledLevel/Content/map/blue.png new file mode 100644 index 00000000..3f8a9068 Binary files /dev/null and b/samples/TiledLevel/Content/map/blue.png differ diff --git a/samples/TiledLevel/Content/map/bombStroked.png b/samples/TiledLevel/Content/map/bombStroked.png new file mode 100644 index 00000000..2db2a2a3 Binary files /dev/null and b/samples/TiledLevel/Content/map/bombStroked.png differ diff --git a/samples/TiledLevel/Content/map/castleWall.png b/samples/TiledLevel/Content/map/castleWall.png new file mode 100644 index 00000000..b270467d Binary files /dev/null and b/samples/TiledLevel/Content/map/castleWall.png differ diff --git a/samples/TiledLevel/Content/map/cloud.png b/samples/TiledLevel/Content/map/cloud.png new file mode 100644 index 00000000..f51cdce8 Binary files /dev/null and b/samples/TiledLevel/Content/map/cloud.png differ diff --git a/samples/TiledLevel/Content/map/column1.png b/samples/TiledLevel/Content/map/column1.png new file mode 100644 index 00000000..292d20d1 Binary files /dev/null and b/samples/TiledLevel/Content/map/column1.png differ diff --git a/samples/TiledLevel/Content/map/column2.png b/samples/TiledLevel/Content/map/column2.png new file mode 100644 index 00000000..0169d957 Binary files /dev/null and b/samples/TiledLevel/Content/map/column2.png differ diff --git a/samples/TiledLevel/Content/map/doorBlueStroked.png b/samples/TiledLevel/Content/map/doorBlueStroked.png new file mode 100644 index 00000000..b2052604 Binary files /dev/null and b/samples/TiledLevel/Content/map/doorBlueStroked.png differ diff --git a/samples/TiledLevel/Content/map/doorGreenStroke.png b/samples/TiledLevel/Content/map/doorGreenStroke.png new file mode 100644 index 00000000..3d565db0 Binary files /dev/null and b/samples/TiledLevel/Content/map/doorGreenStroke.png differ diff --git a/samples/TiledLevel/Content/map/doorRedStroked.png b/samples/TiledLevel/Content/map/doorRedStroked.png new file mode 100644 index 00000000..dfeb1b75 Binary files /dev/null and b/samples/TiledLevel/Content/map/doorRedStroked.png differ diff --git a/samples/TiledLevel/Content/map/doorStroked.png b/samples/TiledLevel/Content/map/doorStroked.png new file mode 100644 index 00000000..4891c39c Binary files /dev/null and b/samples/TiledLevel/Content/map/doorStroked.png differ diff --git a/samples/TiledLevel/Content/map/earthWall.png b/samples/TiledLevel/Content/map/earthWall.png new file mode 100644 index 00000000..1729a1bb Binary files /dev/null and b/samples/TiledLevel/Content/map/earthWall.png differ diff --git a/samples/TiledLevel/Content/map/earthWall2.png b/samples/TiledLevel/Content/map/earthWall2.png new file mode 100644 index 00000000..ed08586a Binary files /dev/null and b/samples/TiledLevel/Content/map/earthWall2.png differ diff --git a/samples/TiledLevel/Content/map/exit.png b/samples/TiledLevel/Content/map/exit.png new file mode 100644 index 00000000..4a3a5b7b Binary files /dev/null and b/samples/TiledLevel/Content/map/exit.png differ diff --git a/samples/TiledLevel/Content/map/flare.png b/samples/TiledLevel/Content/map/flare.png new file mode 100644 index 00000000..52f29e19 Binary files /dev/null and b/samples/TiledLevel/Content/map/flare.png differ diff --git a/samples/TiledLevel/Content/map/gemBlueStroked.png b/samples/TiledLevel/Content/map/gemBlueStroked.png new file mode 100644 index 00000000..ba99cef1 Binary files /dev/null and b/samples/TiledLevel/Content/map/gemBlueStroked.png differ diff --git a/samples/TiledLevel/Content/map/gemRedStroked.png b/samples/TiledLevel/Content/map/gemRedStroked.png new file mode 100644 index 00000000..e2235144 Binary files /dev/null and b/samples/TiledLevel/Content/map/gemRedStroked.png differ diff --git a/samples/TiledLevel/Content/map/grassLarge.png b/samples/TiledLevel/Content/map/grassLarge.png new file mode 100644 index 00000000..93dfcd3a Binary files /dev/null and b/samples/TiledLevel/Content/map/grassLarge.png differ diff --git a/samples/TiledLevel/Content/map/grassSmall.png b/samples/TiledLevel/Content/map/grassSmall.png new file mode 100644 index 00000000..0fe8a00e Binary files /dev/null and b/samples/TiledLevel/Content/map/grassSmall.png differ diff --git a/samples/TiledLevel/Content/map/grey.png b/samples/TiledLevel/Content/map/grey.png new file mode 100644 index 00000000..5a948f1b Binary files /dev/null and b/samples/TiledLevel/Content/map/grey.png differ diff --git a/samples/TiledLevel/Content/map/hero.png b/samples/TiledLevel/Content/map/hero.png new file mode 100644 index 00000000..1957fb84 Binary files /dev/null and b/samples/TiledLevel/Content/map/hero.png differ diff --git a/samples/TiledLevel/Content/map/keyGreenStroked.png b/samples/TiledLevel/Content/map/keyGreenStroked.png new file mode 100644 index 00000000..7ed454bd Binary files /dev/null and b/samples/TiledLevel/Content/map/keyGreenStroked.png differ diff --git a/samples/TiledLevel/Content/map/keyRedStroked.png b/samples/TiledLevel/Content/map/keyRedStroked.png new file mode 100644 index 00000000..1407a376 Binary files /dev/null and b/samples/TiledLevel/Content/map/keyRedStroked.png differ diff --git a/samples/TiledLevel/Content/map/keyYellowStroked.png b/samples/TiledLevel/Content/map/keyYellowStroked.png new file mode 100644 index 00000000..78aeeedd Binary files /dev/null and b/samples/TiledLevel/Content/map/keyYellowStroked.png differ diff --git a/samples/TiledLevel/Content/map/objs.tsx b/samples/TiledLevel/Content/map/objs.tsx new file mode 100644 index 00000000..7dfc66c7 --- /dev/null +++ b/samples/TiledLevel/Content/map/objs.tsx @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/TiledLevel/Content/map/platform1.png b/samples/TiledLevel/Content/map/platform1.png new file mode 100644 index 00000000..e634bed4 Binary files /dev/null and b/samples/TiledLevel/Content/map/platform1.png differ diff --git a/samples/TiledLevel/Content/map/platform2.png b/samples/TiledLevel/Content/map/platform2.png new file mode 100644 index 00000000..3bfa4413 Binary files /dev/null and b/samples/TiledLevel/Content/map/platform2.png differ diff --git a/samples/TiledLevel/Content/map/platform3.png b/samples/TiledLevel/Content/map/platform3.png new file mode 100644 index 00000000..d2938f9d Binary files /dev/null and b/samples/TiledLevel/Content/map/platform3.png differ diff --git a/samples/TiledLevel/Content/map/platform4.png b/samples/TiledLevel/Content/map/platform4.png new file mode 100644 index 00000000..39c53fd6 Binary files /dev/null and b/samples/TiledLevel/Content/map/platform4.png differ diff --git a/samples/TiledLevel/Content/map/platformBase1.png b/samples/TiledLevel/Content/map/platformBase1.png new file mode 100644 index 00000000..7a08f947 Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBase1.png differ diff --git a/samples/TiledLevel/Content/map/platformBase2.png b/samples/TiledLevel/Content/map/platformBase2.png new file mode 100644 index 00000000..074bbe5d Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBase2.png differ diff --git a/samples/TiledLevel/Content/map/platformBase3.png b/samples/TiledLevel/Content/map/platformBase3.png new file mode 100644 index 00000000..1edfff98 Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBase3.png differ diff --git a/samples/TiledLevel/Content/map/platformBase4.png b/samples/TiledLevel/Content/map/platformBase4.png new file mode 100644 index 00000000..e142526b Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBase4.png differ diff --git a/samples/TiledLevel/Content/map/platformBlock1.png b/samples/TiledLevel/Content/map/platformBlock1.png new file mode 100644 index 00000000..c2ab0eec Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBlock1.png differ diff --git a/samples/TiledLevel/Content/map/platformBlock2.png b/samples/TiledLevel/Content/map/platformBlock2.png new file mode 100644 index 00000000..39d6493f Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBlock2.png differ diff --git a/samples/TiledLevel/Content/map/platformBlock3.png b/samples/TiledLevel/Content/map/platformBlock3.png new file mode 100644 index 00000000..12337a8c Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBlock3.png differ diff --git a/samples/TiledLevel/Content/map/platformBlock4.png b/samples/TiledLevel/Content/map/platformBlock4.png new file mode 100644 index 00000000..5ce2c188 Binary files /dev/null and b/samples/TiledLevel/Content/map/platformBlock4.png differ diff --git a/samples/TiledLevel/Content/map/platformConnector1.png b/samples/TiledLevel/Content/map/platformConnector1.png new file mode 100644 index 00000000..5b035690 Binary files /dev/null and b/samples/TiledLevel/Content/map/platformConnector1.png differ diff --git a/samples/TiledLevel/Content/map/platformConnector2.png b/samples/TiledLevel/Content/map/platformConnector2.png new file mode 100644 index 00000000..5671b0ef Binary files /dev/null and b/samples/TiledLevel/Content/map/platformConnector2.png differ diff --git a/samples/TiledLevel/Content/map/platformConnector3.png b/samples/TiledLevel/Content/map/platformConnector3.png new file mode 100644 index 00000000..095f0146 Binary files /dev/null and b/samples/TiledLevel/Content/map/platformConnector3.png differ diff --git a/samples/TiledLevel/Content/map/platformConnector4.png b/samples/TiledLevel/Content/map/platformConnector4.png new file mode 100644 index 00000000..b859ee4c Binary files /dev/null and b/samples/TiledLevel/Content/map/platformConnector4.png differ diff --git a/samples/TiledLevel/Content/map/pushBlock1.png b/samples/TiledLevel/Content/map/pushBlock1.png new file mode 100644 index 00000000..9b7156ef Binary files /dev/null and b/samples/TiledLevel/Content/map/pushBlock1.png differ diff --git a/samples/TiledLevel/Content/map/pushBlock2.png b/samples/TiledLevel/Content/map/pushBlock2.png new file mode 100644 index 00000000..c3977cb5 Binary files /dev/null and b/samples/TiledLevel/Content/map/pushBlock2.png differ diff --git a/samples/TiledLevel/Content/map/pushBlock3.png b/samples/TiledLevel/Content/map/pushBlock3.png new file mode 100644 index 00000000..28778ddd Binary files /dev/null and b/samples/TiledLevel/Content/map/pushBlock3.png differ diff --git a/samples/TiledLevel/Content/map/sandbox.tmx b/samples/TiledLevel/Content/map/sandbox.tmx new file mode 100644 index 00000000..072963fd --- /dev/null +++ b/samples/TiledLevel/Content/map/sandbox.tmx @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/TiledLevel/Content/map/shadow.png b/samples/TiledLevel/Content/map/shadow.png new file mode 100644 index 00000000..c43af09a Binary files /dev/null and b/samples/TiledLevel/Content/map/shadow.png differ diff --git a/samples/TiledLevel/Content/map/shieldStroked.png b/samples/TiledLevel/Content/map/shieldStroked.png new file mode 100644 index 00000000..976f171a Binary files /dev/null and b/samples/TiledLevel/Content/map/shieldStroked.png differ diff --git a/samples/TiledLevel/Content/map/sign.png b/samples/TiledLevel/Content/map/sign.png new file mode 100644 index 00000000..47ec0ac0 Binary files /dev/null and b/samples/TiledLevel/Content/map/sign.png differ diff --git a/samples/TiledLevel/Content/map/skeleton.png b/samples/TiledLevel/Content/map/skeleton.png new file mode 100644 index 00000000..84040a12 Binary files /dev/null and b/samples/TiledLevel/Content/map/skeleton.png differ diff --git a/samples/TiledLevel/Content/map/swordStroked.png b/samples/TiledLevel/Content/map/swordStroked.png new file mode 100644 index 00000000..a3b68745 Binary files /dev/null and b/samples/TiledLevel/Content/map/swordStroked.png differ diff --git a/samples/TiledLevel/Content/map/templates/block.tx b/samples/TiledLevel/Content/map/templates/block.tx new file mode 100644 index 00000000..3bb9a817 --- /dev/null +++ b/samples/TiledLevel/Content/map/templates/block.tx @@ -0,0 +1,11 @@ + + diff --git a/samples/TiledLevel/Content/map/templates/diamond.tx b/samples/TiledLevel/Content/map/templates/diamond.tx new file mode 100644 index 00000000..01b0dd10 --- /dev/null +++ b/samples/TiledLevel/Content/map/templates/diamond.tx @@ -0,0 +1,5 @@ + + diff --git a/samples/TiledLevel/Content/map/templates/hero.tx b/samples/TiledLevel/Content/map/templates/hero.tx new file mode 100644 index 00000000..793b2f54 --- /dev/null +++ b/samples/TiledLevel/Content/map/templates/hero.tx @@ -0,0 +1,5 @@ + + diff --git a/samples/TiledLevel/Content/map/torch.png b/samples/TiledLevel/Content/map/torch.png new file mode 100644 index 00000000..75ca437c Binary files /dev/null and b/samples/TiledLevel/Content/map/torch.png differ diff --git a/samples/TiledLevel/Content/map/trap.png b/samples/TiledLevel/Content/map/trap.png new file mode 100644 index 00000000..7b07731b Binary files /dev/null and b/samples/TiledLevel/Content/map/trap.png differ diff --git a/samples/TiledLevel/Content/map/wallDecor1.png b/samples/TiledLevel/Content/map/wallDecor1.png new file mode 100644 index 00000000..9fadac33 Binary files /dev/null and b/samples/TiledLevel/Content/map/wallDecor1.png differ diff --git a/samples/TiledLevel/Content/map/wallDecor2.png b/samples/TiledLevel/Content/map/wallDecor2.png new file mode 100644 index 00000000..e33ad113 Binary files /dev/null and b/samples/TiledLevel/Content/map/wallDecor2.png differ diff --git a/samples/TiledLevel/Content/map/wallDecor3.png b/samples/TiledLevel/Content/map/wallDecor3.png new file mode 100644 index 00000000..3053883e Binary files /dev/null and b/samples/TiledLevel/Content/map/wallDecor3.png differ diff --git a/samples/TiledLevel/Content/map/window1.png b/samples/TiledLevel/Content/map/window1.png new file mode 100644 index 00000000..255b5712 Binary files /dev/null and b/samples/TiledLevel/Content/map/window1.png differ diff --git a/samples/TiledLevel/Content/map/window2.png b/samples/TiledLevel/Content/map/window2.png new file mode 100644 index 00000000..3a4143ba Binary files /dev/null and b/samples/TiledLevel/Content/map/window2.png differ diff --git a/samples/TiledLevel/Content/map/window3.png b/samples/TiledLevel/Content/map/window3.png new file mode 100644 index 00000000..60398735 Binary files /dev/null and b/samples/TiledLevel/Content/map/window3.png differ diff --git a/samples/TiledLevel/LevelScene.cs b/samples/TiledLevel/LevelScene.cs new file mode 100644 index 00000000..ae930f92 --- /dev/null +++ b/samples/TiledLevel/LevelScene.cs @@ -0,0 +1,99 @@ +using System.Numerics; +using MicroEngine; +using MicroEngine.Graphics; +using MicroEngine.Scenes; +using MicroEngine.Tiled; +using MicroEngine.World; + +namespace TiledLevel; + +/// +/// The level of , played: the hero runs and jumps over the objects whose +/// bodyType property the level designer set, and collects the objects of class coin. +/// +public sealed class LevelScene(TiledLevelGame game) : Scene +{ + public const float Gravity = 2600, RunSpeed = 420, JumpSpeed = 1250, MaxFall = 1600; + // The hero's sprite is 128 × 160; it collides with a narrower box at its feet. + public static readonly Vector2 HeroBox = new(64, 140); + + private readonly List _solids = []; + private readonly List _coins = []; + // The map has no tile layers: everything solid is an object, so the grid is empty and open at its edges. + private readonly TileGrid _noTiles = new(new TileLayer(1, 1), 32, new Dictionary()) { OutsideFlags = TileFlags.None }; + private TiledObject _heroSprite = null!; + private RectF _hero; + private Vector2 _velocity; + private bool _facingLeft; + + /// The hero's collision box, in map pixels. + public RectF Hero => _hero; + /// The solid rectangles read from the map. + public IReadOnlyList Solids => _solids; + /// How many coins the map holds and how many are left. + public int CoinsTotal { get; private set; } + public int CoinsLeft => _coins.Count; + /// Whether the hero stood on something after the last step. + public bool OnGround { get; private set; } + + public override void Enter() + { + var map = game.Map; + #region level + // The map is data: this game decides what its classes and properties mean. + foreach (var o in map.Objects) + { + if (o.Properties.String("bodyType") is "static" or "dynamic") _solids.Add(o.TurnedBounds); + if (o.Class == "coin") _coins.Add(o); + if (o.Class == "hero") + { + _heroSprite = o; + // The box stands where the sprite's feet are: a tile object's position is its bottom-left corner. + _hero = new RectF(o.Position.X + (o.Size.X - HeroBox.X) / 2, o.Position.Y - HeroBox.Y, HeroBox.X, HeroBox.Y); + } + } + #endregion + CoinsTotal = _coins.Count; + WorldCamera = Game.CreateCamera(); + WorldCamera.Bounds = new RectF(0, 0, map.PixelWidth, map.PixelHeight); + WorldCamera.CenterOn(_hero.Center); + } + + public override void Update(float dt) + { + float run = Input.Axis("left", "right"); + if (run != 0) _facingLeft = run < 0; + _velocity.X = run * RunSpeed; + _velocity.Y = MathF.Min(_velocity.Y + Gravity * dt, MaxFall); + if (OnGround && Input.Pressed("confirm")) _velocity.Y = -JumpSpeed; + var move = TileMotion.Move(_hero, _velocity * dt, _noTiles, _solids); + _hero = move.Bounds; + if (move.HitFloor || move.HitCeiling) _velocity.Y = 0; + OnGround = TileMotion.IsOnGround(_hero, _noTiles, _solids); + _coins.RemoveAll(c => c.TurnedBounds.Intersects(_hero)); + WorldCamera!.CenterOn(_hero.Center); + } + + public override void Draw(IRenderer r) + { + #region draw + // Layer by layer, as Tiled shows them, each shifted by its parallax. The "game" layer's objects move, so the + // scene draws them where the game has them instead of where the map placed them. + var map = game.Map; + foreach (var layer in map.ObjectLayers) + { + var shift = map.ParallaxShift(layer, WorldCamera!.Center); + if (layer.Name != "game") game.Sprites.Draw(r, layer, WorldCamera, Age, shift); + else + { + foreach (var coin in _coins) game.Sprites.Draw(r, coin, Age); + var feet = new Vector2(_hero.Center.X - _heroSprite.Size.X / 2, _hero.Bottom); + game.Sprites.Draw(r, _heroSprite with { Position = feet, Orientation = _facingLeft ? TileOrientation.FlipX : TileOrientation.None }); + } + } + #endregion + } + + public override void DrawUi(IRenderer ui) => + Font.Draw(ui, $"COINS {CoinsTotal - CoinsLeft}/{CoinsTotal} MOVE: ARROWS JUMP: Z", new Vector2(16, 16), Color.White, 2); +} diff --git a/samples/TiledLevel/Program.cs b/samples/TiledLevel/Program.cs new file mode 100644 index 00000000..a8421a6b --- /dev/null +++ b/samples/TiledLevel/Program.cs @@ -0,0 +1,4 @@ +using MicroEngine.Desktop; +using TiledLevel; + +return DesktopLauncher.Run(TiledLevelGame.Configure, args); diff --git a/samples/TiledLevel/TiledLevel.csproj b/samples/TiledLevel/TiledLevel.csproj new file mode 100644 index 00000000..3b5144de --- /dev/null +++ b/samples/TiledLevel/TiledLevel.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + TiledLevel + Tiled Level + com.example.tiled_level + 1.0.0 + A platformer level made in the Tiled editor, loaded with MicroEngine.Tiled. + + + + + + diff --git a/samples/TiledLevel/TiledLevelGame.cs b/samples/TiledLevel/TiledLevelGame.cs new file mode 100644 index 00000000..95656bf3 --- /dev/null +++ b/samples/TiledLevel/TiledLevelGame.cs @@ -0,0 +1,49 @@ +using MicroEngine; +using MicroEngine.Core; +using MicroEngine.Graphics; +using MicroEngine.Hosting; +using MicroEngine.Tiled; + +namespace TiledLevel; + +/// +/// An HD platformer whose level is a map made in the Tiled editor: the map is data, the rules (what collides, what is +/// the hero, what is collected) are this game's. +/// +public sealed class TiledLevelGame : Game +{ + /// The game's composition, called by every build (desktop Program.cs, browser, Android, iOS). + public static void Configure(HostBuilder host) => host.UseGame(() => new TiledLevelGame()); + + private readonly string? _contentRoot; + + /// Creates the game; tests pass the content folder of the sources. + public TiledLevelGame(string? contentRoot = null) => _contentRoot = contentRoot; + + /// + public override GameSettings Settings { get; } = new("Tiled Level", 1280, 720, 1) + { + Presentation = Presentation.HighResolution2D, + Background = Color.Hex("27b99a"), + }; + + /// + protected override string? ContentRoot => _contentRoot; + + /// The level as Tiled saved it. + public TiledMap Map { get; private set; } = null!; + /// The map's tile images, as sprites. + public TiledSprites Sprites { get; private set; } = null!; + + /// + protected override void Load() + { + #region load + // The map, its tileset and its templates come through the game's assets, like any content file; the tileset's + // images become textures here, so nothing loads during play. + Map = Assets.TiledMap("map/sandbox.tmx"); + Sprites = new TiledSprites(Map, Assets); + #endregion + Scenes.Push(new LevelScene(this)); + } +} diff --git a/samples/TiledLevel/scripts/tour.script b/samples/TiledLevel/scripts/tour.script new file mode 100644 index 00000000..2dafea9b --- /dev/null +++ b/samples/TiledLevel/scripts/tour.script @@ -0,0 +1,7 @@ +# The level as loaded, then a run to the right over the first diamonds and a running jump onto the castle platform. +1 hold right 140 +20 capture start.png +31 press confirm +46 capture jump.png +120 capture platform.png +122 quit diff --git a/sdk/tests/docs.mjs b/sdk/tests/docs.mjs index fb431dd1..8a62e768 100644 --- a/sdk/tests/docs.mjs +++ b/sdk/tests/docs.mjs @@ -53,6 +53,7 @@ const samples = [ ['samples/GemGarden/part4-complete', 'GemGarden.csproj', 'scripts/tour.script'], ['samples/Examples', 'Examples.csproj', 'scripts/tour.script'], ['samples/HelloEngine', 'HelloEngine.csproj', 'hello.script'], + ['samples/TiledLevel', 'TiledLevel.csproj', 'scripts/tour.script'], ]; const tail = text => text.trim().split('\n').slice(-4).join(' | '); @@ -108,7 +109,7 @@ async function main() { const referenced = csproj.match(/ { throw new Error('HTTP 503'); } }), /503/); }); -test('wait-public: polls until all eight are served, survives a failed request, gives up at the deadline', async () => { +test('wait-public: polls until every package is served, survives a failed request, gives up at the deadline', async () => { let calls = 0; const fetchJson = async () => { calls++; if (calls === 2) throw new Error('HTTP 503'); return index(calls > 8 ? ['1.0.0'] : []); }; const state = await waitPublic('1.0.0', { fetchJson, sleep: async () => {}, timeoutSeconds: 60 }); @@ -231,8 +231,9 @@ test('release plan: frozen, no state releases, publishes or deploys anything, by }); test('packages: a package a published SDK version did not have is never required of that version', () => { - assert.deepEqual(packagesAt('0.6.0-alpha.1'), packageIds); - assert.deepEqual(packagesAt('0.5.0-alpha.2'), packageIds.filter(id => id !== 'MicroEngine.Physics.Box2D')); + assert.deepEqual(packagesAt('0.6.0-alpha.2'), packageIds); + assert.deepEqual(packagesAt('0.6.0-alpha.1'), packageIds.filter(id => id !== 'MicroEngine.Tiled')); + assert.deepEqual(packagesAt('0.5.0-alpha.2'), packageIds.filter(id => id !== 'MicroEngine.Physics.Box2D' && id !== 'MicroEngine.Tiled')); const withoutBox2D = Object.fromEntries(packageIds.map(id => [id, id !== 'MicroEngine.Physics.Box2D'])); const published = { documented: '0.5.0-alpha.2', prepared: '0.5.0-alpha.2', packages: withoutBox2D, release: { draft: false, target: 'a'.repeat(40) } }; const plan = releasePlan(state(published)); diff --git a/sdk/version.mjs b/sdk/version.mjs index 7d90f3c6..5fe6c6a3 100644 --- a/sdk/version.mjs +++ b/sdk/version.mjs @@ -33,12 +33,13 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -/** The eight SDK packages, engine first: the tool and the template must never point at a version nuget.org lacks. */ -export const packageIds = ['MicroEngine', 'MicroEngine.Sdl', 'MicroEngine.Raylib', 'MicroEngine.Desktop', 'MicroEngine.Text.Shaping', 'MicroEngine.Physics.Box2D', 'MicroEngine.Cli', 'MicroEngine.Templates']; +/** The nine SDK packages, engine first: the tool and the template must never point at a version nuget.org lacks. */ +export const packageIds = ['MicroEngine', 'MicroEngine.Sdl', 'MicroEngine.Raylib', 'MicroEngine.Desktop', 'MicroEngine.Text.Shaping', 'MicroEngine.Physics.Box2D', 'MicroEngine.Tiled', 'MicroEngine.Cli', 'MicroEngine.Templates']; /** Published SDK versions that shipped without a package (it was added later): never required of those versions. */ const shippedWithout = { 'MicroEngine.Text.Shaping': ['0.4.0-alpha.1', '0.4.0-alpha.2', '0.5.0-alpha.1'], 'MicroEngine.Physics.Box2D': ['0.4.0-alpha.1', '0.4.0-alpha.2', '0.5.0-alpha.1', '0.5.0-alpha.2'], + 'MicroEngine.Tiled': ['0.4.0-alpha.1', '0.4.0-alpha.2', '0.5.0-alpha.1', '0.5.0-alpha.2', '0.6.0-alpha.1'], }; /** The packages SDK `version` is made of, in push order. */ export const packagesAt = version => packageIds.filter(id => !shippedWithout[id]?.includes(version.toLowerCase())); diff --git a/site/scripts/api-reference.mjs b/site/scripts/api-reference.mjs index c5752f7d..07951d2d 100644 --- a/site/scripts/api-reference.mjs +++ b/site/scripts/api-reference.mjs @@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const out = path.join(repo, 'docs', 'api'); const check = process.argv.includes('--check'); -const assemblies = ['MicroEngine.Kernel', 'MicroEngine.Platform', 'MicroEngine.Graphics', 'MicroEngine.Graphics2D', 'MicroEngine.Text', 'MicroEngine.Text.Shaping', 'MicroEngine.Input', 'MicroEngine.Input.Touch', 'MicroEngine.UI', 'MicroEngine.Hosting', 'MicroEngine.Content', 'MicroEngine.Audio', 'MicroEngine.Scene', 'MicroEngine.Physics', 'MicroEngine.Physics.Tiles', 'MicroEngine.Saves', 'MicroEngine.Achievements', 'MicroEngine', 'MicroEngine.Sdl', 'MicroEngine.Raylib', 'MicroEngine.Desktop']; +const assemblies = ['MicroEngine.Kernel', 'MicroEngine.Platform', 'MicroEngine.Graphics', 'MicroEngine.Graphics2D', 'MicroEngine.Text', 'MicroEngine.Text.Shaping', 'MicroEngine.Input', 'MicroEngine.Input.Touch', 'MicroEngine.UI', 'MicroEngine.Hosting', 'MicroEngine.Content', 'MicroEngine.Audio', 'MicroEngine.Scene', 'MicroEngine.Physics', 'MicroEngine.Physics.Tiles', 'MicroEngine.Saves', 'MicroEngine.Achievements', 'MicroEngine', 'MicroEngine.Sdl', 'MicroEngine.Raylib', 'MicroEngine.Desktop', 'MicroEngine.Tiled']; const version = fs.readFileSync(path.join(repo, 'Directory.Build.props'), 'utf8').match(/([^<]+) + + + MicroEngine.Tiled + MicroEngine Tiled maps: loads maps made in the Tiled editor (TMX and TMJ, external tilesets, templates, infinite maps) into tile layers, sprites, objects and typed properties, with collision from a tile property the game names. Optional; reference it next to MicroEngine.Desktop. + $(PackageTags);tiled;tmx;tmj;tilemap;level-editor + true + + + + + + + + + + + + diff --git a/src/MicroEngine.Tiled/README.md b/src/MicroEngine.Tiled/README.md new file mode 100644 index 00000000..140bb7be --- /dev/null +++ b/src/MicroEngine.Tiled/README.md @@ -0,0 +1,24 @@ +# MicroEngine.Tiled + +Maps made in the [Tiled](https://www.mapeditor.org/) editor for [MicroEngine](https://github.com/NastMz/MicroEngine): +`.tmx` and `.tmj` maps with their external tilesets and templates, read through the game's assets (folders, archives +and browser packages alike) into the engine's tile types. Tile layers become `TileLayer`s with each cell's flips, +tilesets become sprites, objects become records with typed properties, and a collision grid comes from a tile +property the game names. + +```csharp +var map = Assets.TiledMap("maps/level1.tmx"); +var sprites = new TiledSprites(map, Assets); +var solid = map.CollisionGrid(map.TileLayer("ground"), "collision"); // "solid", "oneway", ... on each tile +foreach (var spawn in map.ObjectLayer("spawns").Objects) + if (spawn.Class == "enemy") SpawnEnemy(spawn.Position, spawn.Properties.Int("hp", 1)); +// In Draw, with the camera pushed: +sprites.DrawAll(renderer, camera, elapsedSeconds); +``` + +The engine gives no class or property a meaning: what an object is, is the game's rule. Only orthogonal maps load. +Maps are parsed by [DotTiled](https://github.com/dcronqvist/DotTiled), which this package's public API never exposes. + +MicroEngine is in **Alpha** (0.x): public APIs may change incompatibly between versions; each change is listed in the +[migration notes](https://github.com/NastMz/MicroEngine/blob/main/docs/migrations.md). MIT licensed; DotTiled is +under MIT. diff --git a/src/MicroEngine.Tiled/TiledAssets.cs b/src/MicroEngine.Tiled/TiledAssets.cs new file mode 100644 index 00000000..020b24a8 --- /dev/null +++ b/src/MicroEngine.Tiled/TiledAssets.cs @@ -0,0 +1,44 @@ +using System.Text; +using MicroEngine.Content; +using MicroEngine.Core; + +namespace MicroEngine.Tiled; + +/// +/// The tiled map asset kind: a Tiled map (.tmx or .tmj) with the tilesets (.tsx, +/// .tsj) and templates (.tx, .tj) it references, read through the store's content source so it +/// loads from folders, archives and browser packages alike. The asset is data (); its +/// tile images become textures with . +/// +public static class TiledAssets +{ + /// The asset kind. + public const string Kind = "tiled map"; + + /// The loader of the tiled map kind (extensions .tmx and .tmj). + public static IAssetLoader Loader { get; } = new AssetLoader(Kind, Load, [".tmx", ".tmj"]); + + /// Registers the tiled map kind on . + /// , for chaining. + public static AssetLoaderRegistry AddTiledMaps(this AssetLoaderRegistry loaders) + { + ArgumentNullException.ThrowIfNull(loaders); + return loaders.Add(Loader); + } + + /// + /// The map at , loaded once per scope like any asset (the kind is registered on first use). + /// + /// The game's assets (Game.Assets) or one of its scopes. + /// The map's content path. + /// The map, or a file it references, is missing or not a map Tiled saves; or the map is not orthogonal. + public static TiledMap TiledMap(this AssetCache assets, string path) + { + ArgumentNullException.ThrowIfNull(assets); + if (!assets.Loaders.TryGet(Kind, out _)) assets.Loaders.AddTiledMaps(); + return assets.Scope.Get(assets.Scope.Load(Kind, path)); + } + + private static TiledMap Load(AssetLoadContext context) => + TiledReader.Read(context.Path, file => Encoding.UTF8.GetString(context.ReadBytes(file))); +} diff --git a/src/MicroEngine.Tiled/TiledMap.cs b/src/MicroEngine.Tiled/TiledMap.cs new file mode 100644 index 00000000..ae2f2408 --- /dev/null +++ b/src/MicroEngine.Tiled/TiledMap.cs @@ -0,0 +1,413 @@ +using System.Numerics; +using MicroEngine.Graphics; +using MicroEngine.World; + +namespace MicroEngine.Tiled; + +/// +/// A map made in the Tiled editor, read as data: its size, layers, tilesets and objects. Load it with +/// ; give its tiles textures with . +/// +/// +/// Positions are in map pixels: tile (0, 0) has its top-left at (0, 0). An infinite map is cropped to the cells its +/// layers use, and says where that first cell was in Tiled. Only orthogonal maps load. +/// +public sealed class TiledMap +{ + private readonly Dictionary _tiles; + private readonly Dictionary _objectsById; + + internal TiledMap(string path, int width, int height, int tileWidth, int tileHeight, (int X, int Y) origin, Color? background, + string className, TiledProperties properties, IReadOnlyList layers, IReadOnlyList tilesets) + { + (Path, Width, Height, TileWidth, TileHeight, Origin, Background, Class, Properties, Layers, Tilesets) = + (path, width, height, tileWidth, tileHeight, origin, background, className, properties, layers, tilesets); + _tiles = []; + foreach (var tileset in tilesets) + foreach (var tile in tileset.Tiles) _tiles[tile.Gid] = tile; + _objectsById = []; + foreach (var o in Objects) _objectsById[o.Id] = o; + } + + /// The map's content path. + public string Path { get; } + /// Width in tiles. + public int Width { get; } + /// Height in tiles. + public int Height { get; } + /// Width of a grid cell in pixels. + public int TileWidth { get; } + /// Height of a grid cell in pixels. + public int TileHeight { get; } + /// Width of the map in pixels. + public int PixelWidth => Width * TileWidth; + /// Height of the map in pixels. + public int PixelHeight => Height * TileHeight; + /// Where tile (0, 0) was in Tiled, in tiles: (0, 0) except for an infinite map, which is cropped to the cells it uses. + public (int X, int Y) Origin { get; } + /// The point where parallax shifts nothing, in map pixels (Tiled's parallax origin). + public Vector2 ParallaxOrigin { get; init; } + + /// + /// How far moves from its place for a view centred on , as + /// Tiled shows it: (view centre − ) × (1 − the layer's parallax factor). Add it to + /// where the layer is drawn; it is zero for layers that move with the map. + /// + public Vector2 ParallaxShift(TiledLayer layer, Vector2 viewCenter) + { + ArgumentNullException.ThrowIfNull(layer); + return (viewCenter - ParallaxOrigin) * (Vector2.One - layer.Parallax); + } + + /// The background colour set in Tiled, if any. + public Color? Background { get; } + /// The map's class (Tiled's "Class" field); empty when unset. + public string Class { get; } + /// The map's custom properties. + public TiledProperties Properties { get; } + /// The top-level layers, bottom to top; groups hold their own (). + public IReadOnlyList Layers { get; } + /// The tilesets, by increasing . + public IReadOnlyList Tilesets { get; } + + /// Every layer, groups included, in drawing order (bottom to top, a group before its layers). + public IEnumerable AllLayers => Flatten(Layers); + + /// Every tile layer in drawing order. + public IEnumerable TileLayers => AllLayers.OfType(); + + /// Every object layer in drawing order. + public IEnumerable ObjectLayers => AllLayers.OfType(); + + /// Every object of every object layer, layer by layer. + public IEnumerable Objects => ObjectLayers.SelectMany(l => l.Objects); + + private static IEnumerable Flatten(IEnumerable layers) + { + foreach (var layer in layers) + { + yield return layer; + if (layer is TiledGroupLayer group) + foreach (var inner in Flatten(group.Layers)) yield return inner; + } + } + + /// The first layer called of type , groups included. + /// There is none; the message lists the map's layers. + public T Layer(string name) where T : TiledLayer => + AllLayers.OfType().FirstOrDefault(l => l.Name == name) + ?? throw new KeyNotFoundException($"{Path} has no {typeof(T).Name} '{name}'. Layers: {string.Join(", ", AllLayers.Select(l => $"{l.Name} ({l.GetType().Name})"))}."); + + /// The tile layer called (see ). + public TiledTileLayer TileLayer(string name) => Layer(name); + + /// The object layer called (see ). + public TiledObjectLayer ObjectLayer(string name) => Layer(name); + + /// The tile with global id , or null when no tileset holds it (0 included). + public TiledTile? Tile(int gid) => _tiles.GetValueOrDefault(gid); + + /// The object with id (what an object property refers to), or null. + public TiledObject? Object(int id) => _objectsById.GetValueOrDefault(id); + + /// + /// A collision grid over whose flags come from each tile's : + /// a bool property set to true is ; a text property lists flag names separated by + /// commas, bars or spaces (solid, oneway, hazard, water, custom1 to + /// custom4, any case); a whole number is the flags' bits. Tiles without the property have no flags. + /// + /// The map's cells are not square. + /// A tile's property names an unknown flag or has another type; the message names the tile. + public TileGrid CollisionGrid(TiledTileLayer layer, string property) + { + ArgumentException.ThrowIfNullOrWhiteSpace(property); + return CollisionGrid(layer, tile => FlagsOf(tile, property)); + } + + /// A collision grid over whose flags chooses for each tile. + /// The map's cells are not square. + public TileGrid CollisionGrid(TiledTileLayer layer, Func flagsOf) + { + ArgumentNullException.ThrowIfNull(layer); + ArgumentNullException.ThrowIfNull(flagsOf); + if (TileWidth != TileHeight) throw new NotSupportedException($"{Path}: collision grids need square cells, not {TileWidth} × {TileHeight}."); + var flags = new Dictionary(); + foreach (var tile in _tiles.Values) + if (flagsOf(tile) is var f && f != TileFlags.None) flags[tile.Gid] = f; + return new TileGrid(layer.Tiles, TileWidth, flags); + } + + private static TileFlags FlagsOf(TiledTile tile, string property) + { + if (!tile.Properties.TryGet(property, out var p)) return TileFlags.None; + switch (p.Value) + { + case bool b: return b ? TileFlags.Solid : TileFlags.None; + case int bits when p.Type == TiledPropertyType.Int: return (TileFlags)bits; + case string text when p.Type is TiledPropertyType.String or TiledPropertyType.Enum: + var flags = TileFlags.None; + foreach (var name in text.Split([',', '|', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + flags |= name.ToLowerInvariant() switch + { + "solid" => TileFlags.Solid, + "oneway" => TileFlags.OneWay, + "hazard" => TileFlags.Hazard, + "water" => TileFlags.Water, + "custom1" => TileFlags.Custom1, + "custom2" => TileFlags.Custom2, + "custom3" => TileFlags.Custom3, + "custom4" => TileFlags.Custom4, + _ => throw new InvalidDataException($"Tile {tile.Id} of tileset '{tile.Tileset.Name}': '{property}' names an unknown flag '{name}' (solid, oneway, hazard, water, custom1 to custom4)."), + }; + return flags; + default: + throw new InvalidDataException($"Tile {tile.Id} of tileset '{tile.Tileset.Name}': '{property}' is {p.Type}; a collision property is a bool, a whole number or a list of flag names."); + } + } +} + +/// A layer of a : what every kind of layer has. +public abstract class TiledLayer +{ + private protected TiledLayer(TiledLayerInfo info) => + (Id, Name, Class, Visible, Opacity, Tint, Offset, Parallax, Properties) = + (info.Id, info.Name, info.Class, info.Visible, info.Opacity, info.Tint, info.Offset, info.Parallax, info.Properties); + + /// The layer's id, unique in the map. + public int Id { get; } + /// The layer's name. + public string Name { get; } + /// The layer's class; empty when unset. + public string Class { get; } + /// Whether it is shown; a hidden group hides its layers. + public bool Visible { get; } + /// Opacity from 0 to 1, a group's included. + public float Opacity { get; } + /// The tint colour, a group's included (white when unset). + public Color Tint { get; } + /// Where the layer is drawn relative to the map, in pixels, a group's offset included. + public Vector2 Offset { get; } + /// The parallax factor, a group's included ((1, 1) moves with the map). The engine does not apply it when drawing. + public Vector2 Parallax { get; } + /// The layer's custom properties. + public TiledProperties Properties { get; } + + /// The layer's name and type. + public override string ToString() => $"{GetType().Name} '{Name}'"; +} + +internal sealed record TiledLayerInfo(int Id, string Name, string Class, bool Visible, float Opacity, Color Tint, Vector2 Offset, Vector2 Parallax, TiledProperties Properties); + +/// A layer of tiles: one global tile id per cell (0 for none) and how each cell's tile is turned. +public sealed class TiledTileLayer : TiledLayer +{ + private readonly TileOrientation[] _orientations; + + internal TiledTileLayer(TiledLayerInfo info, TileLayer tiles, TileOrientation[] orientations) : base(info) => + (Tiles, _orientations) = (tiles, orientations); + + /// + /// The global tile ids, the size of the map: gives each one's tileset, properties and + /// animation. Changing a cell here changes what is drawn and collided with. + /// + public TileLayer Tiles { get; } + + /// How the tile at (, ) is mirrored or turned (none outside the map). + public TileOrientation OrientationAt(int x, int y) => Tiles.InBounds(x, y) ? _orientations[y * Tiles.Width + x] : TileOrientation.None; + + /// Sets how the tile at (, ) is mirrored or turned; ignored outside the map. + public void SetOrientation(int x, int y, TileOrientation orientation) + { + if (Tiles.InBounds(x, y)) _orientations[y * Tiles.Width + x] = orientation; + } +} + +/// A layer of objects: placements, areas and points with their data. +public sealed class TiledObjectLayer : TiledLayer +{ + internal TiledObjectLayer(TiledLayerInfo info, IReadOnlyList objects, Color? color) : base(info) => + (Objects, Color) = (objects, color); + + /// The objects in the order Tiled stores them. + public IReadOnlyList Objects { get; } + /// The colour Tiled shows the layer's objects in, if set. + public Color? Color { get; } +} + +/// A layer showing one image. +public sealed class TiledImageLayer : TiledLayer +{ + internal TiledImageLayer(TiledLayerInfo info, string? image, bool repeatX, bool repeatY) : base(info) => + (Image, RepeatX, RepeatY) = (image, repeatX, repeatY); + + /// The image's content path, or null when the layer has none. + public string? Image { get; } + /// Whether the image repeats horizontally. + public bool RepeatX { get; } + /// Whether the image repeats vertically. + public bool RepeatY { get; } +} + +/// A group of layers. +public sealed class TiledGroupLayer : TiledLayer +{ + internal TiledGroupLayer(TiledLayerInfo info, IReadOnlyList layers) : base(info) => Layers = layers; + + /// The group's layers, bottom to top. + public IReadOnlyList Layers { get; } +} + +/// The shape of a . +public enum TiledShape +{ + /// A rectangle of from . + Rectangle, + /// An ellipse inside the rectangle. + Ellipse, + /// A point: the size is zero. + Point, + /// A closed polygon through . + Polygon, + /// An open line through . + Polyline, + /// Text () in the rectangle. + Text, + /// A tile () stretched to the rectangle. + Tile, +} + +/// An object placed on an object layer, or a collision shape of a tile, with its data. +/// The object's id, unique in the map (0 for a tile's collision shapes). +/// Its name; empty when unset. +/// Its class (Tiled's "Class", formerly "Type"); for a tile object without one, the tile's class. +/// What kind of object it is. +/// Where it is in map pixels, its layer's offset included: the top-left of its rectangle, the point, or the origin of its points. +/// Its width and height in pixels (zero for points, polygons and polylines). +/// Clockwise rotation in radians about . +/// Whether Tiled shows it. +/// The points of a polygon or polyline, relative to ; empty otherwise. +/// For a tile object, the global id of its tile; 0 otherwise. +/// For a tile object, how its tile is mirrored. +/// For a text object, its text; null otherwise. +/// Its custom properties; a tile object's include its tile's, its own winning. +public sealed record TiledObject(int Id, string Name, string Class, TiledShape Shape, Vector2 Position, Vector2 Size, float Rotation, + bool Visible, IReadOnlyList Points, int Gid, TileOrientation Orientation, string? Text, TiledProperties Properties) +{ + /// + /// The rectangle the object covers without rotation: its rectangle, the box around its points, or an empty + /// rectangle at a point. A tile object's rectangle goes up from its position, as Tiled draws it. + /// + public RectF Bounds + { + get + { + if (Points.Count > 0) + { + float x0 = Points.Min(p => p.X), y0 = Points.Min(p => p.Y), x1 = Points.Max(p => p.X), y1 = Points.Max(p => p.Y); + return new RectF(Position.X + x0, Position.Y + y0, x1 - x0, y1 - y0); + } + return Shape == TiledShape.Tile + ? new RectF(Position.X, Position.Y - Size.Y, Size.X, Size.Y) + : new RectF(Position.X, Position.Y, Size.X, Size.Y); + } + } + + /// The centre of . + public Vector2 Center => Bounds.Center; + + /// + /// The axis-aligned rectangle around the object as Tiled shows it turned by about + /// ; when it is not turned. A quarter turn of a box gives its exact box. + /// + public RectF TurnedBounds + { + get + { + var b = Bounds; + if (Rotation == 0) return b; + var (sin, cos) = MathF.SinCos(Rotation); + float x0 = float.MaxValue, y0 = float.MaxValue, x1 = float.MinValue, y1 = float.MinValue; + foreach (var corner in (ReadOnlySpan)[new(b.X, b.Y), new(b.Right, b.Y), new(b.X, b.Bottom), new(b.Right, b.Bottom)]) + { + var d = corner - Position; + // Rounded to 1/1000 pixel so a quarter turn of a box gives whole numbers back. + float x = MathF.Round(Position.X + d.X * cos - d.Y * sin, 3), y = MathF.Round(Position.Y + d.X * sin + d.Y * cos, 3); + (x0, y0, x1, y1) = (MathF.Min(x0, x), MathF.Min(y0, y), MathF.Max(x1, x), MathF.Max(y1, y)); + } + return new RectF(x0, y0, x1 - x0, y1 - y0); + } + } +} + +/// A tileset of a : one image cut into tiles, or a collection of images. +public sealed class TiledTileset +{ + internal TiledTileset(string name, int firstGid, int tileWidth, int tileHeight, int columns, string? image, Vector2 tileOffset, + string className, TiledProperties properties) + { + (Name, FirstGid, TileWidth, TileHeight, Columns, Image, TileOffset, Class, Properties) = + (name, firstGid, tileWidth, tileHeight, columns, image, tileOffset, className, properties); + Tiles = []; + } + + /// The tileset's name. + public string Name { get; } + /// The global id of its first tile. + public int FirstGid { get; } + /// Width of its tiles in pixels (the largest, for a collection of images). + public int TileWidth { get; } + /// Height of its tiles in pixels (the largest, for a collection of images). + public int TileHeight { get; } + /// Tiles per row of its image (0 for a collection of images). + public int Columns { get; } + /// The content path of its image, or null for a collection of images (each tile has its own). + public string? Image { get; } + /// Pixels every tile is drawn shifted by. + public Vector2 TileOffset { get; } + /// The tileset's class; empty when unset. + public string Class { get; } + /// The tileset's custom properties. + public TiledProperties Properties { get; } + /// Its tiles by increasing id: every cell of its image, or the images of a collection (whose ids may have gaps). + public IReadOnlyList Tiles { get; internal set; } + + /// Whether is one of this tileset's tiles. + public bool Contains(int gid) => Tiles.Count > 0 && gid >= FirstGid && gid <= Tiles[^1].Gid && Tiles.Any(t => t.Gid == gid); +} + +/// One frame of a tile animation. +/// The global id of the tile shown. +/// How long it is shown. +public readonly record struct TiledFrame(int Gid, float Seconds); + +/// A tile of a tileset: where its pixels are and its data. +/// The tileset holding it. +/// Its id in the tileset. +/// Its global id in the map. +/// The content path of the image its pixels are in. +/// Its rectangle in that image, in pixels. +/// Its class; empty when unset. +/// Its custom properties. +/// Its animation frames; empty when it does not animate. +/// Its collision shapes drawn in Tiled's tile collision editor, in tile pixels; empty when none. +public sealed record TiledTile(TiledTileset Tileset, int Id, int Gid, string Image, RectF Source, string Class, TiledProperties Properties, + IReadOnlyList Animation, IReadOnlyList Shapes) +{ + /// The length of one loop of in seconds (0 when it does not animate). + public float AnimationLength => Animation.Sum(f => f.Seconds); + + /// The global id shown into the animation (looping), or when it does not animate. + public int FrameAt(double seconds) + { + float length = AnimationLength; + if (length <= 0) return Gid; + double t = seconds % length; + if (t < 0) t += length; + foreach (var frame in Animation) + { + if (t < frame.Seconds) return frame.Gid; + t -= frame.Seconds; + } + return Animation[^1].Gid; + } +} diff --git a/src/MicroEngine.Tiled/TiledProperties.cs b/src/MicroEngine.Tiled/TiledProperties.cs new file mode 100644 index 00000000..ce422860 --- /dev/null +++ b/src/MicroEngine.Tiled/TiledProperties.cs @@ -0,0 +1,112 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + + +namespace MicroEngine.Tiled; + +/// The type of a custom property, as Tiled names it. +public enum TiledPropertyType +{ + /// Text: the value is a . + String, + /// A whole number: the value is an . + Int, + /// A number: the value is a . + Float, + /// True or false: the value is a . + Bool, + /// A colour: the value is a . + Color, + /// A file: the value is its content path (a , relative to the content root). + File, + /// A reference to another object: the value is its id (an , 0 for none). + Object, + /// A custom enum: the value is a (several flags are joined with commas). + Enum, + /// A custom class: the value is a of its members. + Class, +} + +/// One custom property: its name, type and value (see for the value's type). +/// The property's name. +/// The kind of value. +/// The value. +/// The name of the custom enum or class, for and ; otherwise null. +public sealed record TiledProperty(string Name, TiledPropertyType Type, object Value, string? CustomType = null); + +/// +/// The custom properties of a map, layer, tileset, tile or object, read as data: the engine gives no property a +/// meaning. Each getter returns its fallback when the property is missing, and throws when it exists with another type. +/// +public sealed class TiledProperties : IReadOnlyCollection +{ + private readonly Dictionary _byName; + + /// No properties. + public static TiledProperties Empty { get; } = new([]); + + /// Creates a set of properties; a name given twice keeps its last value. + public TiledProperties(IEnumerable properties) + { + ArgumentNullException.ThrowIfNull(properties); + _byName = new Dictionary(StringComparer.Ordinal); + foreach (var p in properties) _byName[p.Name] = p; + } + + /// How many properties there are. + public int Count => _byName.Count; + + /// Whether a property called exists. + public bool Has(string name) => _byName.ContainsKey(name); + + /// The property called , if it exists. + public bool TryGet(string name, [NotNullWhen(true)] out TiledProperty? property) => _byName.TryGetValue(name, out property); + + /// The property called . + /// There is none; the message lists those there are. + public TiledProperty this[string name] => _byName.TryGetValue(name, out var p) ? p + : throw new KeyNotFoundException($"No property '{name}'. Properties: {(Count == 0 ? "none" : string.Join(", ", _byName.Keys.Order(StringComparer.Ordinal)))}."); + + /// A text, enum or file property. + /// The property holds another type. + public string? String(string name, string? fallback = null) => + Get(name, fallback, TiledPropertyType.String, TiledPropertyType.Enum, TiledPropertyType.File); + + /// A whole-number or object property. + /// The property holds another type. + public int Int(string name, int fallback = 0) => Get(name, fallback, TiledPropertyType.Int, TiledPropertyType.Object); + + /// A number property; a whole-number property converts. + /// The property holds another type. + public float Float(string name, float fallback = 0) => + _byName.TryGetValue(name, out var p) && p.Type == TiledPropertyType.Int ? (int)p.Value : Get(name, fallback, TiledPropertyType.Float); + + /// A true-or-false property. + /// The property holds another type. + public bool Bool(string name, bool fallback = false) => Get(name, fallback, TiledPropertyType.Bool); + + /// A colour property. + /// The property holds another type. + public MicroEngine.Color Color(string name, MicroEngine.Color fallback = default) => Get(name, fallback, TiledPropertyType.Color); + + /// A class property's members ( when it is missing). + /// The property holds another type. + public TiledProperties Class(string name) => Get(name, Empty, TiledPropertyType.Class); + + private T Get(string name, T fallback, params TiledPropertyType[] types) + { + if (!_byName.TryGetValue(name, out var p)) return fallback; + if (!types.Contains(p.Type) || p.Value is not T value) + throw new InvalidCastException($"Property '{name}' is {p.Type}, not {string.Join(" or ", types)}."); + return value; + } + + /// + public IEnumerator GetEnumerator() => _byName.Values.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// The properties as "name=value" pairs, for diagnostics. + public override string ToString() => + string.Join(", ", _byName.Values.Select(p => $"{p.Name}={Convert.ToString(p.Value, CultureInfo.InvariantCulture)}")); +} diff --git a/src/MicroEngine.Tiled/TiledReader.cs b/src/MicroEngine.Tiled/TiledReader.cs new file mode 100644 index 00000000..fe1af2a9 --- /dev/null +++ b/src/MicroEngine.Tiled/TiledReader.cs @@ -0,0 +1,283 @@ +using System.Numerics; +using DotTiled; +using DotTiled.Serialization; +using MicroEngine.World; +using DT = DotTiled; + +namespace MicroEngine.Tiled; + +/// Turns DotTiled's model of a map into the engine's types. Nothing of DotTiled leaves this file. +internal static class TiledReader +{ + /// Reads the map at ; returns the text of any content file. + public static TiledMap Read(string path, Func read) + { + var files = new Files(read); + DT.Map map; + try + { + using var reader = new MapReader(read(path), files.TilesetsFrom(Folder(path)), files.TemplatesFrom(Folder(path)), Files.NoCustomTypes); + map = reader.ReadMap(); + } + catch (Exception e) when (e is System.Xml.XmlException or System.Text.Json.JsonException or FormatException or NotSupportedException or KeyNotFoundException or ArgumentException) + { + throw new InvalidDataException($"{path}: not a Tiled map Tiled can read: {e.Message}", e); + } + if (map.Orientation != MapOrientation.Orthogonal) + throw new NotSupportedException($"{path}: {map.Orientation} maps are not supported; only orthogonal maps load."); + return new Builder(path, map, files).Build(); + } + + /// + /// The files a map references, each resolved against the folder of the file that names it (DotTiled 1.0.0's own + /// loader resolves a template's tileset against the map's folder). Templates are read once; tilesets are read for + /// every reference, because the reader writes each reference's first global id into the tileset it gets. + /// + private sealed class Files(Func read) + { + public static Optional NoCustomTypes(string name) => Optional.Empty; + + private readonly Dictionary _templates = new(StringComparer.Ordinal); + + public Func TilesetsFrom(string folder) => source => + { + string file = Join(folder, source); + using var reader = new TilesetReader(read(file), TilesetsFrom(Folder(file)), TemplatesFrom(Folder(file)), NoCustomTypes); + return reader.ReadTileset(); + }; + + public Func TemplatesFrom(string folder) => source => + { + string file = Join(folder, source); + if (_templates.TryGetValue(file, out var template)) return template; + using var reader = new TemplateReader(read(file), TilesetsFrom(Folder(file)), TemplatesFrom(Folder(file)), NoCustomTypes); + return _templates[file] = reader.ReadTemplate(); + }; + + /// The template at relative to , if it was read. + public DT.Template? Template(string folder, string source) => _templates.GetValueOrDefault(Join(folder, source)); + } + + private static string Folder(string path) + { + int slash = path.LastIndexOf('/'); + return slash < 0 ? "" : path[..slash]; + } + + private static string Join(string folder, string relative) => + MicroEngine.Content.AssetPath.Normalize(folder.Length == 0 ? relative : folder + "/" + relative); + + private static T Or(Optional optional, T fallback) => optional.HasValue ? optional.Value : fallback; + + private static Color ToColor(TiledColor c) => new(c.R, c.G, c.B, c.A); + + private static TileOrientation ToOrientation(FlippingFlags flags) => + ((flags & FlippingFlags.FlippedHorizontally) != 0 ? TileOrientation.FlipX : 0) + | ((flags & FlippingFlags.FlippedVertically) != 0 ? TileOrientation.FlipY : 0) + | ((flags & FlippingFlags.FlippedDiagonally) != 0 ? TileOrientation.Diagonal : 0); + + private sealed class Builder(string path, DT.Map map, Files files) + { + private readonly string _folder = Folder(path); + private readonly List _tilesets = []; + private readonly Dictionary _tiles = []; + private int _originX, _originY, _width, _height; + + public TiledMap Build() + { + foreach (var tileset in map.Tilesets.OrderBy(t => Or(t.FirstGID, 1u))) _tilesets.Add(Tileset(tileset)); + (_originX, _originY, _width, _height) = map.Infinite ? UsedBounds(map.Layers) : (0, 0, (int)map.Width, (int)map.Height); + var root = new TiledLayerInfo(0, "", "", true, 1, Color.White, Vector2.Zero, Vector2.One, TiledProperties.Empty); + var layers = map.Layers.Select(l => Layer(l, root)).ToList(); + return new TiledMap(path, _width, _height, (int)map.TileWidth, (int)map.TileHeight, (_originX, _originY), + map.BackgroundColor is { A: > 0 } bg ? ToColor(bg) : null, + Or(map.Class, ""), Properties(map.Properties), layers, _tilesets) + { + // An infinite map is cropped, so its parallax origin moves with it. + ParallaxOrigin = new Vector2(map.ParallaxOriginX - _originX * (int)map.TileWidth, map.ParallaxOriginY - _originY * (int)map.TileHeight), + }; + } + + // ---- Tilesets ---- + + private TiledTileset Tileset(DT.Tileset t) + { + // An external tileset's files are relative to the tileset, an embedded one's to the map. + string folder = t.Source.HasValue ? Folder(Join(_folder, t.Source.Value)) : _folder; + string? image = t.Image.HasValue ? Join(folder, t.Image.Value.Source.Value) : null; + var offset = t.TileOffset.HasValue ? new Vector2(t.TileOffset.Value.X, t.TileOffset.Value.Y) : Vector2.Zero; + int firstGid = (int)Or(t.FirstGID, 1u); + var tileset = new TiledTileset(Or(t.Name, ""), firstGid, (int)t.TileWidth, (int)t.TileHeight, (int)t.Columns, image, offset, + Or(t.Class, ""), Properties(t.Properties)); + var described = t.Tiles.ToDictionary(x => (int)x.ID); + // A tileset cut from one image has TileCount tiles; a collection of images has the ids it lists (maybe with gaps). + var ids = image is not null ? Enumerable.Range(0, (int)t.TileCount) : described.Keys.Order(); + var tiles = new List(); + foreach (int id in ids) + { + described.TryGetValue(id, out var d); + string tileImage = image ?? (d?.Image.HasValue == true ? Join(folder, d.Image.Value.Source.Value) : ""); + RectF source; + if (image is not null) + { + // The cell of the image: columns, margin around the image and spacing between tiles. + int columns = Math.Max(1, (int)t.Columns), tw = (int)t.TileWidth, th = (int)t.TileHeight; + int margin = (int)t.Margin, spacing = (int)t.Spacing; + source = new RectF(margin + id % columns * (tw + spacing), margin + id / columns * (th + spacing), tw, th); + } + else if (d is not null && d.Image.HasValue) + { + var img = d.Image.Value; + float w = d.Width > 0 ? d.Width : img.Width.HasValue ? img.Width.Value : 0, h = d.Height > 0 ? d.Height : img.Height.HasValue ? img.Height.Value : 0; + source = new RectF(d.X, d.Y, w, h); + } + else source = default; + var animation = d is null ? [] : d.Animation.Select(f => new TiledFrame(firstGid + (int)f.TileID, f.Duration / 1000f)).ToList(); + var shapes = d?.ObjectLayer is { HasValue: true } group ? group.Value.Objects.Select(o => Object(o, Vector2.Zero, isShape: true)).ToList() : []; + var tile = new TiledTile(tileset, id, firstGid + id, tileImage, source, d is null ? "" : d.Type, d is null ? TiledProperties.Empty : Properties(d.Properties), + animation, shapes); + tiles.Add(tile); + _tiles[firstGid + id] = tile; + } + tileset.Tiles = tiles; + return tileset; + } + + // ---- Layers ---- + + private TiledLayerInfo Info(BaseLayer l, TiledLayerInfo parent) + { + var tint = l.TintColor.HasValue ? ToColor(l.TintColor.Value) : Color.White; + var combined = new Color((byte)(tint.R * parent.Tint.R / 255), (byte)(tint.G * parent.Tint.G / 255), (byte)(tint.B * parent.Tint.B / 255), (byte)(tint.A * parent.Tint.A / 255)); + return new TiledLayerInfo((int)l.ID, l.Name, Or(l.Class, ""), l.Visible && parent.Visible, l.Opacity * parent.Opacity, combined, + parent.Offset + new Vector2(l.OffsetX, l.OffsetY), parent.Parallax * new Vector2(l.ParallaxX, l.ParallaxY), Properties(l.Properties)); + } + + private TiledLayer Layer(BaseLayer l, TiledLayerInfo parent) + { + var info = Info(l, parent); + return l switch + { + DT.TileLayer t => TileLayer(t, info), + ObjectLayer o => new TiledObjectLayer(info, o.Objects.Select(x => Object(x, info.Offset, isShape: false)).ToList(), + o.Color.HasValue ? ToColor(o.Color.Value) : null), + ImageLayer i => new TiledImageLayer(info, i.Image.HasValue ? Join(_folder, i.Image.Value.Source.Value) : null, Or(i.RepeatX, false), Or(i.RepeatY, false)), + Group g => new TiledGroupLayer(info, g.Layers.Select(x => Layer(x, info)).ToList()), + _ => throw new NotSupportedException($"{path}: layer '{l.Name}' is a {l.GetType().Name}, which is not supported."), + }; + } + + private TiledTileLayer TileLayer(DT.TileLayer t, TiledLayerInfo info) + { + var tiles = new World.TileLayer(_width, _height); + var orientations = new TileOrientation[_width * _height]; + void Put(int x, int y, uint gid, FlippingFlags flags) + { + x -= _originX; + y -= _originY; + if (gid == 0 || x < 0 || y < 0 || x >= _width || y >= _height) return; + tiles[x, y] = (int)gid; + orientations[y * _width + x] = ToOrientation(flags); + } + if (!t.Data.HasValue) return new TiledTileLayer(info, tiles, orientations); + var data = t.Data.Value; + if (data.Chunks.HasValue) + { + foreach (var chunk in data.Chunks.Value) + for (int i = 0; i < chunk.GlobalTileIDs.Length; i++) + Put(chunk.X + i % (int)chunk.Width, chunk.Y + i / (int)chunk.Width, chunk.GlobalTileIDs[i], chunk.FlippingFlags[i]); + } + else if (data.GlobalTileIDs.HasValue) + { + var gids = data.GlobalTileIDs.Value; + var flags = data.FlippingFlags.HasValue ? data.FlippingFlags.Value : null; + int width = (int)t.Width; + for (int i = 0; i < gids.Length; i++) + Put(Or(t.X, 0) + i % width, Or(t.Y, 0) + i / width, gids[i], flags is null ? FlippingFlags.None : flags[i]); + } + return new TiledTileLayer(info, tiles, orientations); + } + + private static (int X, int Y, int Width, int Height) UsedBounds(IEnumerable layers) + { + int x0 = int.MaxValue, y0 = int.MaxValue, x1 = int.MinValue, y1 = int.MinValue; + void Visit(IEnumerable ls) + { + foreach (var l in ls) + { + if (l is Group g) Visit(g.Layers); + if (l is not DT.TileLayer { Data.HasValue: true } t || !t.Data.Value.Chunks.HasValue) continue; + foreach (var c in t.Data.Value.Chunks.Value) + for (int i = 0; i < c.GlobalTileIDs.Length; i++) + { + if (c.GlobalTileIDs[i] == 0) continue; + int x = c.X + i % (int)c.Width, y = c.Y + i / (int)c.Width; + (x0, y0, x1, y1) = (Math.Min(x0, x), Math.Min(y0, y), Math.Max(x1, x), Math.Max(y1, y)); + } + } + } + Visit(layers); + return x0 == int.MaxValue ? (0, 0, 0, 0) : (x0, y0, x1 - x0 + 1, y1 - y0 + 1); + } + + // ---- Objects ---- + + private TiledObject Object(DT.Object o, Vector2 layerOffset, bool isShape) + { + var shift = isShape ? Vector2.Zero : new Vector2(-_originX * (int)map.TileWidth, -_originY * (int)map.TileHeight); + var position = new Vector2(o.X, o.Y) + layerOffset + shift; + var size = new Vector2(o.Width, o.Height); + IReadOnlyList points = []; + int gid = 0; + var orientation = TileOrientation.None; + string? text = null; + string className = o.Type; + var properties = Properties(o.Properties); + // An instance of a template has the template's properties, its own winning. DotTiled 1.0.0 merges them for + // TMX but replaces them for TMJ, so they are merged here from the template read (a no-op for TMX). + if (o.Template.HasValue && files.Template(_folder, o.Template.Value) is { } template) + properties = new TiledProperties(Properties(template.Object.Properties).Concat(properties)); + TiledShape shape; + switch (o) + { + case EllipseObject: shape = TiledShape.Ellipse; break; + case PointObject: shape = TiledShape.Point; size = Vector2.Zero; break; + case PolygonObject p: shape = TiledShape.Polygon; points = p.Points.ToList(); size = Vector2.Zero; break; + case PolylineObject p: shape = TiledShape.Polyline; points = p.Points.ToList(); size = Vector2.Zero; break; + case TextObject t: shape = TiledShape.Text; text = t.Text; break; + case TileObject t: + shape = TiledShape.Tile; + gid = (int)t.GID; + orientation = ToOrientation(t.FlippingFlags); + if (_tiles.TryGetValue(gid, out var tile)) + { + if (className.Length == 0) className = tile.Class; + properties = new TiledProperties(tile.Properties.Concat(properties)); + } + break; + default: shape = TiledShape.Rectangle; break; + } + return new TiledObject(isShape ? 0 : (int)o.ID.Value, o.Name, className, shape, position, size, o.Rotation * MathF.PI / 180, + o.Visible, points, gid, orientation, text, properties); + } + + // ---- Properties ---- + + private TiledProperties Properties(IEnumerable properties) => + properties is null ? TiledProperties.Empty : new TiledProperties(properties.Select(Property)); + + private TiledProperty Property(IProperty p) => p switch + { + StringProperty s => new TiledProperty(s.Name, TiledPropertyType.String, s.Value), + IntProperty i => new TiledProperty(i.Name, TiledPropertyType.Int, i.Value), + FloatProperty f => new TiledProperty(f.Name, TiledPropertyType.Float, f.Value), + BoolProperty b => new TiledProperty(b.Name, TiledPropertyType.Bool, b.Value), + ColorProperty c => new TiledProperty(c.Name, TiledPropertyType.Color, c.Value.HasValue ? ToColor(c.Value.Value) : default(Color)), + FileProperty f => new TiledProperty(f.Name, TiledPropertyType.File, f.Value.Length == 0 ? "" : Join(_folder, f.Value)), + ObjectProperty o => new TiledProperty(o.Name, TiledPropertyType.Object, (int)o.Value), + EnumProperty e => new TiledProperty(e.Name, TiledPropertyType.Enum, string.Join(",", e.Value), e.PropertyType), + ClassProperty c => new TiledProperty(c.Name, TiledPropertyType.Class, Properties(c.Value), c.PropertyType), + _ => throw new NotSupportedException($"{path}: property '{p.Name}' has the unsupported type {p.Type}."), + }; + } +} diff --git a/src/MicroEngine.Tiled/TiledSprites.cs b/src/MicroEngine.Tiled/TiledSprites.cs new file mode 100644 index 00000000..1f2236cd --- /dev/null +++ b/src/MicroEngine.Tiled/TiledSprites.cs @@ -0,0 +1,139 @@ +using System.Numerics; +using MicroEngine.Core; +using MicroEngine.Graphics; +using MicroEngine.World; + +namespace MicroEngine.Tiled; + +/// +/// The sprites of a 's tiles: each tileset image loaded as a texture through the game's assets +/// (shared and released with them), and a draw call for tile layers. +/// +public sealed class TiledSprites +{ + private readonly Sprite?[] _byGid; + private readonly int _margin; + + /// Loads every tileset image of through . + /// The map. + /// The game's assets (Game.Assets) or the scope the level's textures belong to. + /// The textures' sampling; null for the assets' default (the game's presentation). + /// A tileset image is missing or cannot be decoded. + public TiledSprites(TiledMap map, AssetCache assets, TextureFilter? filter = null) + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(assets); + Map = map; + int last = map.Tilesets.Count == 0 ? 0 : map.Tilesets.SelectMany(t => t.Tiles).Select(t => t.Gid + 1).DefaultIfEmpty(0).Max(); + _byGid = new Sprite?[last]; + foreach (var tileset in map.Tilesets) + foreach (var tile in tileset.Tiles) + { + if (tile.Image.Length == 0 || tile.Source.Width <= 0 || tile.Source.Height <= 0) continue; + var texture = assets.Texture(tile.Image, filter); + // Tiled draws a tile from the bottom-left of its cell, so a tile taller than the grid goes up. + var origin = new Vector2(0, tile.Source.Height - map.TileHeight) - tileset.TileOffset; + _byGid[tile.Gid] = new Sprite(texture, tile.Source, origin); + _margin = Math.Max(_margin, (int)MathF.Ceiling(Math.Max(tile.Source.Width, tile.Source.Height))); + } + } + + /// The map the sprites are for. + public TiledMap Map { get; } + + /// The sprite of the tile with global id , or null (0, unknown ids and tiles without an image). + public Sprite? this[int gid] => gid > 0 && gid < _byGid.Length ? _byGid[gid] : null; + + /// The sprite of into its animation (its own sprite when it does not animate). + public Sprite? At(int gid, double seconds) => Map.Tile(gid) is { } tile ? this[tile.FrameAt(seconds)] : this[gid]; + + /// + /// Draws the cells of the camera shows, each tile turned as the map says and animated at + /// , at the layer's offset, with its opacity and tint. A hidden layer draws nothing; + /// parallax is not applied. + /// + /// The renderer; push on it first when there is one. + /// A tile layer of . + /// The camera, used to skip what it does not show; null draws over the screen area. + /// The time animations are at (for example the scene's elapsed time). + /// Pixels added to the layer's offset, for example its parallax (). + public void Draw(IRenderer renderer, TiledTileLayer layer, Camera2D? camera = null, double seconds = 0, Vector2 shift = default) + { + ArgumentNullException.ThrowIfNull(renderer); + ArgumentNullException.ThrowIfNull(layer); + if (!layer.Visible || layer.Opacity <= 0) return; + if (Map.TileWidth != Map.TileHeight) throw new NotSupportedException($"{Map.Path}: drawing needs square cells, not {Map.TileWidth} × {Map.TileHeight}."); + TileRenderer.Draw(renderer, layer.Tiles, Map.TileWidth, camera, (gid, _, _) => At(gid, seconds), layer.OrientationAt, + layer.Offset + shift, TintOf(layer), _margin); + } + + /// + /// Draws the visible tile objects of that the camera shows, in the layer's order, with + /// the layer's opacity and tint. Other objects (rectangles, points, text...) are data and draw nothing. + /// + /// The renderer; push on it first when there is one. + /// An object layer of . + /// The camera, used to skip what it does not show; null draws every object. + /// The time animations are at. + /// Pixels added to every object's position, for example the layer's parallax. + public void Draw(IRenderer renderer, TiledObjectLayer layer, Camera2D? camera = null, double seconds = 0, Vector2 shift = default) + { + ArgumentNullException.ThrowIfNull(renderer); + ArgumentNullException.ThrowIfNull(layer); + if (!layer.Visible || layer.Opacity <= 0) return; + var tint = TintOf(layer); + foreach (var o in layer.Objects) + { + if (!o.Visible || o.Shape != TiledShape.Tile) continue; + if (camera is not null) + { + // A turned object stays inside the square its longest side spins in. + var b = o.Bounds; + float reach = o.Rotation == 0 ? 0 : Math.Max(b.Width, b.Height); + if (!camera.IsVisible(new RectF(b.X + shift.X - reach, b.Y + shift.Y - reach, b.Width + 2 * reach, b.Height + 2 * reach))) continue; + } + Draw(renderer, o, seconds, shift, tint); + } + } + + /// + /// Draws a tile object as Tiled shows it: its tile stretched to its size, standing on its position (the + /// bottom-left corner), mirrored as it says and turned about that corner. Draws nothing for other objects. + /// + /// The renderer. + /// A tile object of . + /// The time animations are at. + /// Pixels added to its position. + /// Colour it is multiplied by; null for white. + public void Draw(IRenderer renderer, TiledObject tileObject, double seconds = 0, Vector2 shift = default, Color? tint = null) + { + ArgumentNullException.ThrowIfNull(renderer); + ArgumentNullException.ThrowIfNull(tileObject); + if (tileObject.Shape != TiledShape.Tile || At(tileObject.Gid, seconds) is not { } sprite) return; + var source = sprite.Source; + var size = tileObject.Size == Vector2.Zero ? new Vector2(source.Width, source.Height) : tileObject.Size; + var scale = size / new Vector2(source.Width, source.Height); + var origin = new Vector2(0, source.Height); + var offset = Map.Tile(tileObject.Gid)?.Tileset.TileOffset ?? Vector2.Zero; + var at = tileObject.Position + shift + offset - origin * scale; + renderer.Draw(sprite.Texture, source, at, tint ?? Color.White, new DrawOptions( + FlipX: (tileObject.Orientation & TileOrientation.FlipX) != 0, FlipY: (tileObject.Orientation & TileOrientation.FlipY) != 0, + Rotation: tileObject.Rotation, Origin: origin, Scale: scale)); + } + + /// + /// Draws every visible layer of the map that has something to draw, bottom to top: tile layers and the tile + /// objects of object layers, each shifted by its parallax when a camera is given. + /// + public void DrawAll(IRenderer renderer, Camera2D? camera = null, double seconds = 0) + { + foreach (var layer in Map.AllLayers) + { + var shift = camera is null ? Vector2.Zero : Map.ParallaxShift(layer, camera.Center); + if (layer is TiledTileLayer tiles) Draw(renderer, tiles, camera, seconds, shift); + else if (layer is TiledObjectLayer objects) Draw(renderer, objects, camera, seconds, shift); + } + } + + private static Color TintOf(TiledLayer layer) => layer.Tint with { A = (byte)MathF.Round(layer.Tint.A * Math.Clamp(layer.Opacity, 0, 1)) }; +} diff --git a/src/MicroEngine/World/TileOrientation.cs b/src/MicroEngine/World/TileOrientation.cs new file mode 100644 index 00000000..f6cbe2ff --- /dev/null +++ b/src/MicroEngine/World/TileOrientation.cs @@ -0,0 +1,48 @@ +using MicroEngine.Graphics; + +namespace MicroEngine.World; + +/// +/// How one cell's tile is mirrored or turned, as map editors store it (Tiled's flip bits). The diagonal flip (x and y +/// swapped) applies first, then the horizontal and the vertical flips, so the eight combinations cover every quarter +/// turn with or without a mirror: Diagonal | FlipX is a quarter turn clockwise, Diagonal | FlipY a +/// quarter turn anticlockwise, FlipX | FlipY a half turn. +/// +[Flags] +public enum TileOrientation : byte +{ + /// Drawn as it is in the tileset. + None = 0, + /// Mirrored left to right. + FlipX = 1, + /// Mirrored top to bottom. + FlipY = 2, + /// Mirrored across the top-left to bottom-right diagonal (x and y swapped), before the other flips. + Diagonal = 4, +} + +/// Turns a into drawing options. +public static class TileOrientations +{ + /// + /// The flips and rotation that draw a tile of pixels in , + /// turning about the tile's centre so it stays in its cell. Pass the result to with + /// the tile's top-left as the position. + /// + public static DrawOptions ToDrawOptions(this TileOrientation orientation, System.Numerics.Vector2 size) + { + bool x = (orientation & TileOrientation.FlipX) != 0, y = (orientation & TileOrientation.FlipY) != 0; + if ((orientation & TileOrientation.Diagonal) == 0) return new DrawOptions(FlipX: x, FlipY: y); + // The renderer mirrors the source, then turns it clockwise. The diagonal flip is a three-quarter turn of the + // mirrored tile; adding the horizontal flip cancels the mirror and leaves a quarter turn, and so on. + const float Quarter = MathF.PI / 2; + var origin = size / 2; + return (x, y) switch + { + (false, false) => new DrawOptions(FlipX: true, Rotation: 3 * Quarter, Origin: origin), + (true, false) => new DrawOptions(Rotation: Quarter, Origin: origin), + (false, true) => new DrawOptions(Rotation: 3 * Quarter, Origin: origin), + _ => new DrawOptions(FlipX: true, Rotation: Quarter, Origin: origin), + }; + } +} diff --git a/src/MicroEngine/World/TileRenderer.cs b/src/MicroEngine/World/TileRenderer.cs index bfa4fc98..c575e95b 100644 --- a/src/MicroEngine/World/TileRenderer.cs +++ b/src/MicroEngine/World/TileRenderer.cs @@ -11,11 +11,33 @@ public static class TileRenderer /// Tile size in pixels. /// The camera, used only to skip tiles outside its view; null draws the tiles over the screen area. /// Returns the sprite for a tile id at tile (x, y), or null to draw nothing. - public static void Draw(IRenderer renderer, TileLayer layer, int tileSize, Camera2D? camera, Func spriteFor) + public static void Draw(IRenderer renderer, TileLayer layer, int tileSize, Camera2D? camera, Func spriteFor) => + Draw(renderer, layer, tileSize, camera, spriteFor, null); + + /// + /// Draws the non-zero tiles that can be seen, each at its tile position minus its sprite's origin, mirrored or + /// turned as says. + /// + /// The renderer; push on it first when there is one. + /// The tile ids; 0 is never drawn. + /// Tile size in pixels. + /// The camera, used only to skip tiles outside what it shows (zoom and rotation included); null draws the tiles over the screen area. + /// Returns the sprite for a tile id at tile (x, y), or null to draw nothing. + /// The orientation of the tile at (x, y); null draws every tile as it is. A turned tile turns about its sprite's centre. + /// Where tile (0, 0) is drawn, in world pixels (the layer's offset). + /// Colour every tile is multiplied by (its alpha is the layer's opacity); null for white. + /// Extra pixels around the view whose tiles are drawn too, for sprites larger than a cell. + public static void Draw(IRenderer renderer, TileLayer layer, int tileSize, Camera2D? camera, Func spriteFor, + Func? orientationAt, System.Numerics.Vector2 offset = default, Color? tint = null, float margin = 0) { ArgumentNullException.ThrowIfNull(renderer); ArgumentNullException.ThrowIfNull(layer); - var view = camera?.View.Inflate(tileSize) ?? new RectF(0, 0, renderer.Width, renderer.Height); + ArgumentNullException.ThrowIfNull(spriteFor); + var color = tint ?? Color.White; + var view = camera is null + ? new RectF(0, 0, renderer.Width, renderer.Height).Inflate(tileSize + margin) + : camera.VisibleBounds.Inflate(tileSize + margin + camera.MaxShake); + view = new RectF(view.X - offset.X, view.Y - offset.Y, view.Width, view.Height); int x0 = Math.Max(0, (int)MathF.Floor(view.X / tileSize)), y0 = Math.Max(0, (int)MathF.Floor(view.Y / tileSize)); int x1 = Math.Min(layer.Width - 1, (int)MathF.Ceiling(view.Right / tileSize)), y1 = Math.Min(layer.Height - 1, (int)MathF.Ceiling(view.Bottom / tileSize)); for (int y = y0; y <= y1; y++) @@ -23,8 +45,11 @@ public static void Draw(IRenderer renderer, TileLayer layer, int tileSize, Camer { int id = layer[x, y]; if (id == 0) continue; - if (spriteFor(id, x, y) is { } sprite) - renderer.Draw(sprite.Texture, sprite.Source, new System.Numerics.Vector2(x * tileSize, y * tileSize) - sprite.Origin, Color.White); + if (spriteFor(id, x, y) is not { } sprite) continue; + var at = offset + new System.Numerics.Vector2(x * tileSize, y * tileSize) - sprite.Origin; + var orientation = orientationAt?.Invoke(x, y) ?? TileOrientation.None; + if (orientation == TileOrientation.None) renderer.Draw(sprite.Texture, sprite.Source, at, color); + else renderer.Draw(sprite.Texture, sprite.Source, at, color, orientation.ToDrawOptions(new System.Numerics.Vector2(sprite.Source.Width, sprite.Source.Height))); } } } diff --git a/tests/MicroEngine.Tests/Kernel/ArchitectureTests.cs b/tests/MicroEngine.Tests/Kernel/ArchitectureTests.cs index ac6174a5..6a64100f 100644 --- a/tests/MicroEngine.Tests/Kernel/ArchitectureTests.cs +++ b/tests/MicroEngine.Tests/Kernel/ArchitectureTests.cs @@ -15,7 +15,7 @@ public sealed class ArchitectureTests private const string Kernel = "MicroEngine.Kernel", Platform = "MicroEngine.Platform", Graphics = "MicroEngine.Graphics", Content = "MicroEngine.Content", Graphics2D = "MicroEngine.Graphics2D", Text = "MicroEngine.Text", Input = "MicroEngine.Input", InputTouch = "MicroEngine.Input.Touch", Audio = "MicroEngine.Audio", Scene = "MicroEngine.Scene", Physics = "MicroEngine.Physics", PhysicsTiles = "MicroEngine.Physics.Tiles", Box2D = "MicroEngine.Physics.Box2D", - TextShaping = "MicroEngine.Text.Shaping", UI = "MicroEngine.UI", Saves = "MicroEngine.Saves", Achievements = "MicroEngine.Achievements", Hosting = "MicroEngine.Hosting", + TextShaping = "MicroEngine.Text.Shaping", Tiled = "MicroEngine.Tiled", UI = "MicroEngine.UI", Saves = "MicroEngine.Saves", Achievements = "MicroEngine.Achievements", Hosting = "MicroEngine.Hosting", Testing = "MicroEngine.Testing", // The transitional entity adapter of the demo migrations, deleted: no project may reference it again. Migration = "MicroEngine.Migration.Entities", @@ -45,6 +45,9 @@ public sealed class ArchitectureTests // Shaped text (an optional package): fonts drawn through the 2D layer. Its package depends on MicroEngine, which // carries the text assembly, so the project references MicroEngine. [TextShaping] = [.. Abstractions, Graphics2D, Text, Core], + // Tiled maps (an optional package, ADR 0052): read through the content store into tile layers (the tile types) and + // sprites (the 2D layer); like shaping, its package depends on MicroEngine. DotTiled is its only other reference. + [Tiled] = [.. Abstractions, Graphics2D, PhysicsTiles, Core], [UI] = [Graphics2D, Text, Input, Graphics, Content, Kernel, Platform], [Saves] = [Kernel, Platform], // Progress persists through the save store (ADR 0006). diff --git a/tests/MicroEngine.Tests/TileRendererTests.cs b/tests/MicroEngine.Tests/TileRendererTests.cs new file mode 100644 index 00000000..5e03b4d0 --- /dev/null +++ b/tests/MicroEngine.Tests/TileRendererTests.cs @@ -0,0 +1,104 @@ +using System.Numerics; +using MicroEngine.Core; +using MicroEngine.Graphics; +using MicroEngine.World; + +namespace MicroEngine.Tests; + +[TestClass] +public sealed class TileRendererTests +{ + private static readonly Color Bg = new(10, 20, 30); + + private static (HeadlessRenderer Renderer, HeadlessGraphicsDevice Device) Reference(int width, int height) + { + var device = new HeadlessGraphicsDevice { KeepsPixels = true }; + var renderer = new HeadlessRenderer(width, height, device); + renderer.Reset(); + renderer.Clear(Bg); + return (renderer, device); + } + + private static TileLayer Filled(int width, int height, int id) + { + var layer = new TileLayer(width, height); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) layer[x, y] = id; + return layer; + } + + [TestMethod] + public void A_zoomed_out_camera_draws_every_tile_it_shows() + { + var (r, _) = Reference(32, 16); + // At zoom 0.5 the 32 × 16 view shows 64 × 32 world pixels: the whole 16 × 8 layer of 4-pixel tiles. + var camera = new Camera2D(32, 16) { Zoom = 0.5f, MaxShake = 0 }; + camera.CenterOn(new Vector2(32, 16)); + var asked = new HashSet<(int, int)>(); + TileRenderer.Draw(r, Filled(16, 8, 1), 4, camera, (_, x, y) => { asked.Add((x, y)); return null; }); + for (int y = 0; y < 8; y++) + for (int x = 0; x < 16; x++) + Assert.Contains((x, y), asked, $"tile ({x},{y}) is on screen"); + } + + [TestMethod] + public void A_camera_at_zoom_one_still_skips_the_tiles_out_of_view() + { + var (r, g) = Reference(8, 8); + var sprite = new Sprite(g.CreateTexture(new PixelImage(1, 1), TextureFilter.Nearest), new RectF(0, 0, 1, 1), Vector2.Zero); + var asked = new HashSet<(int, int)>(); + var camera = new Camera2D(8, 8) { MaxShake = 0 }; + TileRenderer.Draw(r, Filled(100, 100, 1), 1, camera, (_, x, y) => { asked.Add((x, y)); return sprite; }); + Assert.IsTrue(asked.All(c => c.Item1 <= 9 && c.Item2 <= 9), "only the view plus a one-tile margin"); + Assert.IsTrue(asked.Contains((7, 7))); + } + + [TestMethod] + public void A_layer_is_drawn_at_its_offset_with_its_tint() + { + var (r, g) = Reference(8, 4); + var white = new PixelImage(1, 1); + white[0, 0] = Color.White; + var sprite = new Sprite(g.CreateTexture(white, TextureFilter.Nearest), new RectF(0, 0, 1, 1), Vector2.Zero); + var layer = new TileLayer(2, 1); + layer[0, 0] = 1; + TileRenderer.Draw(r, layer, 1, null, (_, _, _) => sprite, null, new Vector2(5, 2), new Color(255, 0, 0)); + Assert.AreEqual(new Color(255, 0, 0), r.Frame![5, 2], "tile (0, 0) at the offset, tinted"); + Assert.HasCount(1, Enumerable.Range(0, 32).Where(i => r.Frame![i % 8, i / 8] != Bg)); + } + + /// The Tiled rule: the diagonal flip (x and y swapped) first, then the horizontal, then the vertical. + private static Color Expected(Color[,] source, int x, int y, TileOrientation o) + { + int n = source.GetLength(0); + for (int sy = 0; sy < n; sy++) + for (int sx = 0; sx < n; sx++) + { + int px = sx, py = sy; + if ((o & TileOrientation.Diagonal) != 0) (px, py) = (py, px); + if ((o & TileOrientation.FlipX) != 0) px = n - 1 - px; + if ((o & TileOrientation.FlipY) != 0) py = n - 1 - py; + if (px == x && py == y) return source[sx, sy]; + } + throw new InvalidOperationException(); + } + + [TestMethod] + public void Every_orientation_draws_the_tile_flipped_and_turned_inside_its_cell() + { + const int n = 3; + var colors = new Color[n, n]; + var image = new PixelImage(n, n); + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) image[x, y] = colors[x, y] = new Color((byte)(40 + x * 80), (byte)(40 + y * 80), 200); + var (r, g) = Reference(8 * n, n); + var sprite = new Sprite(g.CreateTexture(image, TextureFilter.Nearest), new RectF(0, 0, n, n), Vector2.Zero); + var layer = Filled(8, 1, 1); + TileRenderer.Draw(r, layer, n, null, (_, _, _) => sprite, (x, _) => (TileOrientation)x); + var frame = r.Frame!; + for (int cell = 0; cell < 8; cell++) + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + Assert.AreEqual(Expected(colors, x, y, (TileOrientation)cell), frame[cell * n + x, y], $"{(TileOrientation)cell} at ({x},{y})"); + } +} diff --git a/tests/MicroEngine.Tiled.Tests/Maps/generate.mjs b/tests/MicroEngine.Tiled.Tests/Maps/generate.mjs new file mode 100644 index 00000000..218a7402 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/generate.mjs @@ -0,0 +1,114 @@ +// Writes the Tiled fixtures that are binary or encoded (PNGs, the JSON maps, tileset and template): +// node tests/MicroEngine.Tiled.Tests/Maps/generate.mjs tests/MicroEngine.Tiled.Tests/Maps. The XML files are written by hand. +import { deflateSync } from 'node:zlib'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +const root = process.argv[2]; + +function crc32(buf) { + let crc = 0xffffffff; + for (const b of buf) { + let c = (crc ^ b) & 0xff; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crc = (crc >>> 8) ^ c; + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(type), data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); + return Buffer.concat([len, td, crc]); +} +function png(w, h, pixel) { + const raw = Buffer.alloc((w * 4 + 1) * h); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) raw.set(pixel(x, y), y * (w * 4 + 1) + 1 + x * 4); + const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} +// terrain.png: 2 x 2 tiles of 16 px, red, green, blue, yellow, each with a white top-left pixel. +const colors = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255]]; +writeFileSync(join(root, 'tilesets/terrain.png'), png(32, 32, (x, y) => x % 16 === 0 && y % 16 === 0 ? [255, 255, 255, 255] : colors[(y >> 4) * 2 + (x >> 4)])); +// chest.png: 16 x 32, brown, taller than the grid. +writeFileSync(join(root, 'props/chest.png'), png(16, 32, () => [120, 70, 20, 255])); + +const H = 0x80000000, V = 0x40000000, D = 0x20000000; +const ground = [(D | 3) >>> 0, 0, 0, 0, 4, 0, 0, 5, 1, (H | 1) >>> 0, 2, (H | V | 2) >>> 0]; +const data = Buffer.alloc(ground.length * 4); +ground.forEach((g, i) => data.writeUInt32LE(g, i * 4)); +const doorProperties = [ + { name: 'target', type: 'string', value: 'cave' }, + { name: 'locked', type: 'bool', value: true }, + { name: 'cost', type: 'int', value: 3 }, + { name: 'speed', type: 'float', value: 1.5 }, + { name: 'glow', type: 'color', value: '#80ff00ff' }, + { name: 'sprite', type: 'file', value: '../props/chest.png' }, + { name: 'partner', type: 'object', value: 2 }, +]; +const map = { + type: 'map', version: '1.10', tiledversion: '1.11.0', orientation: 'orthogonal', renderorder: 'right-down', + width: 4, height: 3, tilewidth: 16, tileheight: 16, infinite: false, parallaxoriginx: 32, parallaxoriginy: 24, backgroundcolor: '#203040', class: 'dungeon', + compressionlevel: -1, nextlayerid: 11, nextobjectid: 10, + properties: [{ name: 'music', type: 'string', value: 'cave' }], + tilesets: [ + { firstgid: 1, source: '../tilesets/terrain.tsj' }, + { firstgid: 5, name: 'props', tilewidth: 16, tileheight: 32, tilecount: 2, columns: 0, margin: 0, spacing: 0, + grid: { orientation: 'orthogonal', width: 1, height: 1 }, + tiles: [{ id: 0, type: 'chest', image: '../props/chest.png', imagewidth: 16, imageheight: 32, properties: [{ name: 'bonus', type: 'float', value: 0.5 }, { name: 'kind', type: 'string', value: 'treasure' }, { name: 'loot', type: 'int', value: 10 }] }, + { id: 2, image: '../props/chest.png', imagewidth: 16, imageheight: 32 }] }, + ], + layers: [ + { type: 'tilelayer', id: 1, name: 'ground', x: 0, y: 0, width: 4, height: 3, opacity: 1, visible: true, + encoding: 'base64', compression: 'zlib', data: deflateSync(data).toString('base64') }, + { type: 'tilelayer', id: 10, name: 'decor', x: 0, y: 0, width: 4, height: 3, opacity: 1, visible: false, + data: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + { type: 'group', id: 2, name: 'entities', x: 0, y: 0, offsetx: 8, offsety: 0, opacity: 1, visible: true, layers: [ + { type: 'objectgroup', id: 3, name: 'spawns', x: 0, y: 0, offsetx: 0, offsety: 4, opacity: 1, visible: true, draworder: 'topdown', objects: [ + { id: 1, name: 'door', type: 'door', x: 16, y: 16, width: 16, height: 32, rotation: 90, visible: true, properties: doorProperties }, + { id: 2, name: 'spawn', type: '', point: true, x: 32, y: 40, width: 0, height: 0, rotation: 0, visible: true }, + { id: 3, name: '', type: '', ellipse: true, x: 0, y: 0, width: 8, height: 8, rotation: 0, visible: true }, + { id: 4, name: '', type: '', polygon: [{ x: 0, y: 0 }, { x: 8, y: 0 }, { x: 8, y: 8 }], x: 10, y: 10, width: 0, height: 0, rotation: 0, visible: true }, + { id: 5, name: '', type: '', polyline: [{ x: 0, y: 0 }, { x: 5, y: 5 }], x: 20, y: 20, width: 0, height: 0, rotation: 0, visible: false }, + { id: 6, name: '', type: '', text: { text: 'Hello', wrap: false }, x: 0, y: 0, width: 40, height: 10, rotation: 0, visible: true }, + { id: 7, name: '', type: '', gid: (H | 5) >>> 0, x: 48, y: 48, width: 16, height: 32, rotation: 0, visible: true }, + { id: 9, template: 'parts/torch.tj', x: 0, y: 48 }, + { id: 8, template: '../templates/enemy.tj', x: 24, y: 24, properties: [{ name: 'hp', type: 'int', value: 5 }] }, + ] }, + ] }, + { type: 'imagelayer', id: 9, name: 'sky', x: 0, y: 0, opacity: 0.5, parallaxx: 0.5, parallaxy: 0.25, visible: true, image: '../props/chest.png', imagewidth: 16, imageheight: 32, repeatx: true, repeaty: false }, + ], +}; +writeFileSync(join(root, 'maps/level.tmj'), JSON.stringify(map, null, 1)); + +const tileset = { + type: 'tileset', version: '1.10', tiledversion: '1.11.0', name: 'terrain', tilewidth: 16, tileheight: 16, tilecount: 4, columns: 2, + image: 'terrain.png', imagewidth: 32, imageheight: 32, margin: 0, spacing: 0, + properties: [{ name: 'biome', type: 'string', value: 'cave' }], + tiles: [ + { id: 0, properties: [{ name: 'collision', type: 'string', value: 'solid' }] }, + { id: 1, properties: [{ name: 'collision', type: 'string', value: 'oneway' }] }, + { id: 2, animation: [{ tileid: 2, duration: 100 }, { tileid: 3, duration: 300 }] }, + { id: 3, type: 'lava', properties: [{ name: 'collision', type: 'string', value: 'solid, hazard' }] }, + ], +}; +writeFileSync(join(root, 'tilesets/terrain.tsj'), JSON.stringify(tileset, null, 1)); +writeFileSync(join(root, 'maps/parts/torch.tj'), JSON.stringify({ type: 'template', tileset: { firstgid: 1, source: '../../tilesets/terrain.tsj' }, + object: { id: 0, name: 'torch', type: '', gid: 2, x: 0, y: 0, width: 16, height: 16, rotation: 0, visible: true } }, null, 1)); +writeFileSync(join(root, 'templates/enemy.tj'), JSON.stringify({ type: 'template', object: { + id: 0, name: 'enemy', type: 'enemy', x: 0, y: 0, width: 8, height: 8, rotation: 0, visible: true, + properties: [{ name: 'hp', type: 'int', value: 3 }, { name: 'kind', type: 'string', value: 'slime' }] } }, null, 1)); + +// An infinite map: two 16 x 16 chunks, one tile at (-1, -1) and one at (2, 1); an object at (0, 0). +const chunkA = new Array(256).fill(0); chunkA[255] = 1; +const chunkB = new Array(256).fill(0); chunkB[1 * 16 + 2] = 2; +writeFileSync(join(root, 'maps/infinite.tmj'), JSON.stringify({ + type: 'map', version: '1.10', tiledversion: '1.11.0', orientation: 'orthogonal', renderorder: 'right-down', + width: 4, height: 4, tilewidth: 16, tileheight: 16, infinite: true, compressionlevel: -1, nextlayerid: 3, nextobjectid: 2, + tilesets: [{ firstgid: 1, source: '../tilesets/terrain.tsj' }], + layers: [ + { type: 'tilelayer', id: 1, name: 'ground', x: 0, y: 0, startx: -16, starty: -16, width: 32, height: 32, opacity: 1, visible: true, + chunks: [{ x: -16, y: -16, width: 16, height: 16, data: chunkA }, { x: 0, y: 0, width: 16, height: 16, data: chunkB }] }, + { type: 'objectgroup', id: 2, name: 'things', x: 0, y: 0, opacity: 1, visible: true, draworder: 'topdown', + objects: [{ id: 1, name: 'origin', type: '', point: true, x: 0, y: 0, width: 0, height: 0, rotation: 0, visible: true }] }, + ], +}, null, 1)); diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/infinite.tmj b/tests/MicroEngine.Tiled.Tests/Maps/maps/infinite.tmj new file mode 100644 index 00000000..6e81a02e --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/infinite.tmj @@ -0,0 +1,590 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.11.0", + "orientation": "orthogonal", + "renderorder": "right-down", + "width": 4, + "height": 4, + "tilewidth": 16, + "tileheight": 16, + "infinite": true, + "compressionlevel": -1, + "nextlayerid": 3, + "nextobjectid": 2, + "tilesets": [ + { + "firstgid": 1, + "source": "../tilesets/terrain.tsj" + } + ], + "layers": [ + { + "type": "tilelayer", + "id": 1, + "name": "ground", + "x": 0, + "y": 0, + "startx": -16, + "starty": -16, + "width": 32, + "height": 32, + "opacity": 1, + "visible": true, + "chunks": [ + { + "x": -16, + "y": -16, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ] + }, + { + "x": 0, + "y": 0, + "width": 16, + "height": 16, + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + ] + }, + { + "type": "objectgroup", + "id": 2, + "name": "things", + "x": 0, + "y": 0, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "id": 1, + "name": "origin", + "type": "", + "point": true, + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "rotation": 0, + "visible": true + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/isometric.tmx b/tests/MicroEngine.Tiled.Tests/Maps/maps/isometric.tmx new file mode 100644 index 00000000..c1b82f20 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/isometric.tmx @@ -0,0 +1,9 @@ + + + + +0,0, +0,0 + + + diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmj b/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmj new file mode 100644 index 00000000..2da8078a --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmj @@ -0,0 +1,327 @@ +{ + "type": "map", + "version": "1.10", + "tiledversion": "1.11.0", + "orientation": "orthogonal", + "renderorder": "right-down", + "width": 4, + "height": 3, + "tilewidth": 16, + "tileheight": 16, + "infinite": false, + "parallaxoriginx": 32, + "parallaxoriginy": 24, + "backgroundcolor": "#203040", + "class": "dungeon", + "compressionlevel": -1, + "nextlayerid": 11, + "nextobjectid": 10, + "properties": [ + { + "name": "music", + "type": "string", + "value": "cave" + } + ], + "tilesets": [ + { + "firstgid": 1, + "source": "../tilesets/terrain.tsj" + }, + { + "firstgid": 5, + "name": "props", + "tilewidth": 16, + "tileheight": 32, + "tilecount": 2, + "columns": 0, + "margin": 0, + "spacing": 0, + "grid": { + "orientation": "orthogonal", + "width": 1, + "height": 1 + }, + "tiles": [ + { + "id": 0, + "type": "chest", + "image": "../props/chest.png", + "imagewidth": 16, + "imageheight": 32, + "properties": [ + { + "name": "bonus", + "type": "float", + "value": 0.5 + }, + { + "name": "kind", + "type": "string", + "value": "treasure" + }, + { + "name": "loot", + "type": "int", + "value": 10 + } + ] + }, + { + "id": 2, + "image": "../props/chest.png", + "imagewidth": 16, + "imageheight": 32 + } + ] + } + ], + "layers": [ + { + "type": "tilelayer", + "id": 1, + "name": "ground", + "x": 0, + "y": 0, + "width": 4, + "height": 3, + "opacity": 1, + "visible": true, + "encoding": "base64", + "compression": "zlib", + "data": "eJxjZmBQYEACLEhsVgYGBkYIbmBiYGBgYmA4AAAMuAFz" + }, + { + "type": "tilelayer", + "id": 10, + "name": "decor", + "x": 0, + "y": 0, + "width": 4, + "height": 3, + "opacity": 1, + "visible": false, + "data": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "type": "group", + "id": 2, + "name": "entities", + "x": 0, + "y": 0, + "offsetx": 8, + "offsety": 0, + "opacity": 1, + "visible": true, + "layers": [ + { + "type": "objectgroup", + "id": 3, + "name": "spawns", + "x": 0, + "y": 0, + "offsetx": 0, + "offsety": 4, + "opacity": 1, + "visible": true, + "draworder": "topdown", + "objects": [ + { + "id": 1, + "name": "door", + "type": "door", + "x": 16, + "y": 16, + "width": 16, + "height": 32, + "rotation": 90, + "visible": true, + "properties": [ + { + "name": "target", + "type": "string", + "value": "cave" + }, + { + "name": "locked", + "type": "bool", + "value": true + }, + { + "name": "cost", + "type": "int", + "value": 3 + }, + { + "name": "speed", + "type": "float", + "value": 1.5 + }, + { + "name": "glow", + "type": "color", + "value": "#80ff00ff" + }, + { + "name": "sprite", + "type": "file", + "value": "../props/chest.png" + }, + { + "name": "partner", + "type": "object", + "value": 2 + } + ] + }, + { + "id": 2, + "name": "spawn", + "type": "", + "point": true, + "x": 32, + "y": 40, + "width": 0, + "height": 0, + "rotation": 0, + "visible": true + }, + { + "id": 3, + "name": "", + "type": "", + "ellipse": true, + "x": 0, + "y": 0, + "width": 8, + "height": 8, + "rotation": 0, + "visible": true + }, + { + "id": 4, + "name": "", + "type": "", + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 8, + "y": 0 + }, + { + "x": 8, + "y": 8 + } + ], + "x": 10, + "y": 10, + "width": 0, + "height": 0, + "rotation": 0, + "visible": true + }, + { + "id": 5, + "name": "", + "type": "", + "polyline": [ + { + "x": 0, + "y": 0 + }, + { + "x": 5, + "y": 5 + } + ], + "x": 20, + "y": 20, + "width": 0, + "height": 0, + "rotation": 0, + "visible": false + }, + { + "id": 6, + "name": "", + "type": "", + "text": { + "text": "Hello", + "wrap": false + }, + "x": 0, + "y": 0, + "width": 40, + "height": 10, + "rotation": 0, + "visible": true + }, + { + "id": 7, + "name": "", + "type": "", + "gid": 2147483653, + "x": 48, + "y": 48, + "width": 16, + "height": 32, + "rotation": 0, + "visible": true + }, + { + "id": 9, + "template": "parts/torch.tj", + "x": 0, + "y": 48 + }, + { + "id": 8, + "template": "../templates/enemy.tj", + "x": 24, + "y": 24, + "properties": [ + { + "name": "hp", + "type": "int", + "value": 5 + } + ] + } + ] + } + ] + }, + { + "type": "imagelayer", + "id": 9, + "name": "sky", + "x": 0, + "y": 0, + "opacity": 0.5, + "parallaxx": 0.5, + "parallaxy": 0.25, + "visible": true, + "image": "../props/chest.png", + "imagewidth": 16, + "imageheight": 32, + "repeatx": true, + "repeaty": false + } + ] +} \ No newline at end of file diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmx b/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmx new file mode 100644 index 00000000..9c1cae1b --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/level.tmx @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + +536870915,0,0,0, +4,0,0,5, +1,2147483649,2,3221225474 + + + + +0,0,1,0, +0,0,0,0, +0,0,0,0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hello + + + + + + + + + + + + + + diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tj b/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tj new file mode 100644 index 00000000..c6ef70bc --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tj @@ -0,0 +1,19 @@ +{ + "type": "template", + "tileset": { + "firstgid": 1, + "source": "../../tilesets/terrain.tsj" + }, + "object": { + "id": 0, + "name": "torch", + "type": "", + "gid": 2, + "x": 0, + "y": 0, + "width": 16, + "height": 16, + "rotation": 0, + "visible": true + } +} \ No newline at end of file diff --git a/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tx b/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tx new file mode 100644 index 00000000..d55a4866 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/maps/parts/torch.tx @@ -0,0 +1,5 @@ + + diff --git a/tests/MicroEngine.Tiled.Tests/Maps/props/chest.png b/tests/MicroEngine.Tiled.Tests/Maps/props/chest.png new file mode 100644 index 00000000..883fb56e Binary files /dev/null and b/tests/MicroEngine.Tiled.Tests/Maps/props/chest.png differ diff --git a/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tj b/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tj new file mode 100644 index 00000000..0db639bc --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tj @@ -0,0 +1,26 @@ +{ + "type": "template", + "object": { + "id": 0, + "name": "enemy", + "type": "enemy", + "x": 0, + "y": 0, + "width": 8, + "height": 8, + "rotation": 0, + "visible": true, + "properties": [ + { + "name": "hp", + "type": "int", + "value": 3 + }, + { + "name": "kind", + "type": "string", + "value": "slime" + } + ] + } +} \ No newline at end of file diff --git a/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tx b/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tx new file mode 100644 index 00000000..e3b84a18 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/templates/enemy.tx @@ -0,0 +1,9 @@ + + diff --git a/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.png b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.png new file mode 100644 index 00000000..5a7f5acb Binary files /dev/null and b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.png differ diff --git a/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsj b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsj new file mode 100644 index 00000000..8cb6bc3c --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsj @@ -0,0 +1,68 @@ +{ + "type": "tileset", + "version": "1.10", + "tiledversion": "1.11.0", + "name": "terrain", + "tilewidth": 16, + "tileheight": 16, + "tilecount": 4, + "columns": 2, + "image": "terrain.png", + "imagewidth": 32, + "imageheight": 32, + "margin": 0, + "spacing": 0, + "properties": [ + { + "name": "biome", + "type": "string", + "value": "cave" + } + ], + "tiles": [ + { + "id": 0, + "properties": [ + { + "name": "collision", + "type": "string", + "value": "solid" + } + ] + }, + { + "id": 1, + "properties": [ + { + "name": "collision", + "type": "string", + "value": "oneway" + } + ] + }, + { + "id": 2, + "animation": [ + { + "tileid": 2, + "duration": 100 + }, + { + "tileid": 3, + "duration": 300 + } + ] + }, + { + "id": 3, + "type": "lava", + "properties": [ + { + "name": "collision", + "type": "string", + "value": "solid, hazard" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsx b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsx new file mode 100644 index 00000000..96ff1475 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/Maps/tilesets/terrain.tsx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/MicroEngine.Tiled.Tests/MicroEngine.Tiled.Tests.csproj b/tests/MicroEngine.Tiled.Tests/MicroEngine.Tiled.Tests.csproj new file mode 100644 index 00000000..e6eb3bbe --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/MicroEngine.Tiled.Tests.csproj @@ -0,0 +1,18 @@ + + + + true + false + + + + + + + + + + + + + diff --git a/tests/MicroEngine.Tiled.Tests/TiledArchitectureTests.cs b/tests/MicroEngine.Tiled.Tests/TiledArchitectureTests.cs new file mode 100644 index 00000000..3ce3d6bd --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/TiledArchitectureTests.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using MicroEngine.Core; + +namespace MicroEngine.Tiled.Tests; + +/// ADR 0052: the parser stays replaceable, and games without maps do not carry it. +[TestClass] +public sealed class TiledArchitectureTests +{ + private static readonly Assembly Tiled = typeof(TiledMap).Assembly; + + private static IEnumerable Mentioned(Type type) + { + if (type.HasElementType) foreach (var t in Mentioned(type.GetElementType()!)) yield return t; + else yield return type; + if (type.IsGenericType) + foreach (var argument in type.GetGenericArguments()) + foreach (var t in Mentioned(argument)) yield return t; + } + + [TestMethod] + public void No_public_member_exposes_a_type_of_the_parser() + { + const BindingFlags Visible = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + var leaks = new List(); + foreach (var type in Tiled.GetExportedTypes()) + { + var used = new List(); + if (type.BaseType is { } baseType) used.Add(baseType); + used.AddRange(type.GetInterfaces()); + foreach (var member in type.GetMembers(Visible)) + switch (member) + { + case PropertyInfo p: used.Add(p.PropertyType); break; + case FieldInfo f: used.Add(f.FieldType); break; + case MethodBase m: + used.AddRange(m.GetParameters().Select(x => x.ParameterType)); + if (m is MethodInfo mi) used.Add(mi.ReturnType); + break; + } + leaks.AddRange(used.SelectMany(Mentioned).Where(t => t.Assembly.GetName().Name == "DotTiled").Select(t => $"{type.Name} uses {t.FullName}")); + } + Assert.IsEmpty(leaks, string.Join("; ", leaks)); + } + + [TestMethod] + public void The_package_references_only_the_engine_and_the_parser_and_the_engine_never_the_parser() + { + var references = Tiled.GetReferencedAssemblies().Select(a => a.Name!).Where(n => !n.StartsWith("System", StringComparison.Ordinal) && n != "netstandard").ToList(); + Assert.IsTrue(references.All(n => n == "DotTiled" || n.StartsWith("MicroEngine", StringComparison.Ordinal)), string.Join(", ", references)); + CollectionAssert.Contains(references, "DotTiled"); + Assert.IsFalse(typeof(Game).Assembly.GetReferencedAssemblies().Any(a => a.Name == "DotTiled"), "games without maps do not carry the parser"); + } +} diff --git a/tests/MicroEngine.Tiled.Tests/TiledMapTests.cs b/tests/MicroEngine.Tiled.Tests/TiledMapTests.cs new file mode 100644 index 00000000..87446ab1 --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/TiledMapTests.cs @@ -0,0 +1,233 @@ +using System.IO.Compression; +using System.Numerics; +using MicroEngine.Content; +using MicroEngine.Core; +using MicroEngine.Graphics; +using MicroEngine.World; + +namespace MicroEngine.Tiled.Tests; + +[TestClass] +public sealed class TiledMapTests +{ + private static readonly string Maps = Path.Combine(AppContext.BaseDirectory, "Maps"); + + private static AssetCache Assets(bool pixels = false) => new(new HeadlessGraphicsDevice { KeepsPixels = pixels }, Maps); + + [TestMethod] + [DataRow("maps/level.tmx")] + [DataRow("maps/level.tmj")] + public void The_map_its_tilesets_and_its_tile_layer_read_the_same_from_both_formats(string path) + { + var map = Assets().TiledMap(path); + Assert.AreEqual(path, map.Path); + Assert.AreEqual((4, 3, 16, 16, 64, 48), (map.Width, map.Height, map.TileWidth, map.TileHeight, map.PixelWidth, map.PixelHeight)); + Assert.AreEqual((0, 0), map.Origin); + Assert.AreEqual(new Color(0x20, 0x30, 0x40), map.Background); + Assert.AreEqual("dungeon", map.Class); + Assert.AreEqual("cave", map.Properties.String("music")); + CollectionAssert.AreEqual(new[] { "ground", "decor", "entities", "spawns", "sky" }, map.AllLayers.Select(l => l.Name).ToArray()); + CollectionAssert.AreEqual(new[] { "ground", "decor", "entities", "sky" }, map.Layers.Select(l => l.Name).ToArray()); + + var ground = map.TileLayer("ground"); + int[] gids = [3, 0, 0, 0, 4, 0, 0, 5, 1, 1, 2, 2]; + for (int i = 0; i < gids.Length; i++) Assert.AreEqual(gids[i], ground.Tiles[i % 4, i / 4], $"cell {i}"); + Assert.AreEqual(TileOrientation.Diagonal, ground.OrientationAt(0, 0)); + Assert.AreEqual(TileOrientation.FlipX, ground.OrientationAt(1, 2)); + Assert.AreEqual(TileOrientation.FlipX | TileOrientation.FlipY, ground.OrientationAt(3, 2)); + Assert.AreEqual(TileOrientation.None, ground.OrientationAt(0, 2)); + + var terrain = map.Tilesets[0]; + Assert.AreEqual(("terrain", 1, 16, 16, 2, "tilesets/terrain.png"), (terrain.Name, terrain.FirstGid, terrain.TileWidth, terrain.TileHeight, terrain.Columns, terrain.Image)); + Assert.AreEqual("cave", terrain.Properties.String("biome")); + Assert.HasCount(4, terrain.Tiles); + Assert.AreEqual(new RectF(16, 16, 16, 16), map.Tile(4)!.Source, $"{map.Tile(2)!.Source} {map.Tile(3)!.Source} {map.Tile(4)!.Source}"); + Assert.AreEqual("lava", map.Tile(4)!.Class); + CollectionAssert.AreEqual(new[] { new TiledFrame(3, 0.1f), new TiledFrame(4, 0.3f) }, map.Tile(3)!.Animation.ToArray()); + var props = map.Tilesets[1]; + Assert.AreEqual((5, (string?)null), (props.FirstGid, props.Image)); + var chest = map.Tile(5)!; + Assert.AreEqual(("props/chest.png", new RectF(0, 0, 16, 32), "chest", 10), (chest.Image, chest.Source, chest.Class, chest.Properties.Int("loot"))); + Assert.IsNull(map.Tile(0)); + Assert.IsNull(map.Tile(6), "a gap in the collection's ids"); + Assert.AreEqual((7, "props/chest.png"), (map.Tile(7)!.Gid, map.Tile(7)!.Image)); + CollectionAssert.AreEqual(new[] { 0, 2 }, props.Tiles.Select(t => t.Id).ToArray()); + Assert.IsTrue(props.Contains(7)); + Assert.IsFalse(props.Contains(6)); + Assert.IsNull(map.Tile(8)); + + var sky = map.Layer("sky"); + Assert.AreEqual(("props/chest.png", true, false, 0.5f), (sky.Image, sky.RepeatX, sky.RepeatY, sky.Opacity)); + } + + [TestMethod] + [DataRow("maps/level.tmx")] + [DataRow("maps/level.tmj")] + public void Objects_keep_their_shape_data_and_typed_properties_with_their_layers_offsets(string path) + { + var map = Assets().TiledMap(path); + var spawns = map.ObjectLayer("spawns"); + Assert.AreEqual(new Vector2(8, 4), spawns.Offset, "the group's offset plus the layer's"); + Assert.HasCount(9, spawns.Objects); + var offset = new Vector2(8, 4); + + var door = map.Object(1)!; + Assert.AreEqual(("door", "door", TiledShape.Rectangle), (door.Name, door.Class, door.Shape)); + Assert.AreEqual(new Vector2(16, 16) + offset, door.Position); + Assert.AreEqual(new Vector2(16, 32), door.Size); + Assert.AreEqual(MathF.PI / 2, door.Rotation, 1e-5f); + Assert.AreEqual(new RectF(24, 20, 16, 32), door.Bounds); + var p = door.Properties; + Assert.AreEqual(("cave", true, 3, 1.5f, 2), (p.String("target"), p.Bool("locked"), p.Int("cost"), p.Float("speed"), p.Int("partner"))); + Assert.AreEqual(3f, p.Float("cost"), "a whole number reads as a number"); + Assert.AreEqual(new Color(0xff, 0x00, 0xff, 0x80), p.Color("glow")); + Assert.AreEqual("props/chest.png", p.String("sprite"), "a file property is a content path"); + Assert.AreEqual(TiledPropertyType.File, p["sprite"].Type); + Assert.AreSame(map.Object(2), map.Object(p.Int("partner"))); + + Assert.AreEqual((TiledShape.Point, new Vector2(32, 40) + offset, Vector2.Zero), (map.Object(2)!.Shape, map.Object(2)!.Position, map.Object(2)!.Size)); + Assert.AreEqual(TiledShape.Ellipse, map.Object(3)!.Shape); + var polygon = map.Object(4)!; + Assert.AreEqual(TiledShape.Polygon, polygon.Shape); + CollectionAssert.AreEqual(new[] { new Vector2(0, 0), new Vector2(8, 0), new Vector2(8, 8) }, polygon.Points.ToArray()); + Assert.AreEqual(new RectF(18, 14, 8, 8), polygon.Bounds); + Assert.AreEqual((TiledShape.Polyline, false), (map.Object(5)!.Shape, map.Object(5)!.Visible)); + Assert.AreEqual((TiledShape.Text, "Hello"), (map.Object(6)!.Shape, map.Object(6)!.Text)); + + var tile = map.Object(7)!; + Assert.AreEqual((TiledShape.Tile, 5, TileOrientation.FlipX), (tile.Shape, tile.Gid, tile.Orientation)); + Assert.AreEqual("chest", tile.Class, "a tile object without a class takes its tile's"); + Assert.AreEqual(10, tile.Properties.Int("loot"), "and its tile's properties"); + Assert.AreEqual(new RectF(56, 20, 16, 32), tile.Bounds, "a tile object's rectangle goes up from its position"); + + var enemy = map.Object(8)!; + Assert.AreEqual(("enemy", "enemy", new Vector2(8, 8)), (enemy.Name, enemy.Class, enemy.Size), "from the template"); + Assert.AreEqual(5, enemy.Properties.Int("hp"), "the instance's value wins"); + Assert.AreEqual("slime", enemy.Properties.String("kind"), "the template's other properties stay"); + + var torch = map.Object(9)!; + Assert.AreEqual(("torch", TiledShape.Tile, 2), (torch.Name, torch.Shape, torch.Gid), "a template whose tileset path is relative to the template's folder"); + Assert.HasCount(9, map.Objects); + } + + [TestMethod] + public void A_class_property_holds_its_members() + { + var stats = Assets().TiledMap("maps/level.tmx").Object(1)!.Properties; + Assert.AreEqual(("Stats", TiledPropertyType.Class), (stats["stats"].CustomType, stats["stats"].Type)); + Assert.AreEqual(7, stats.Class("stats").Int("hp")); + } + + [TestMethod] + public void Property_getters_give_the_fallback_when_missing_and_refuse_another_type() + { + var p = Assets().TiledMap("maps/level.tmx").Object(1)!.Properties; + Assert.AreEqual((9, "none", false), (p.Int("missing", 9), p.String("missing", "none"), p.Bool("missing"))); + Assert.AreSame(TiledProperties.Empty, p.Class("missing")); + var e = Assert.ThrowsExactly(() => p.Int("target")); + StringAssert.Contains(e.Message, "'target' is String"); + var missing = Assert.ThrowsExactly(() => p["nothing"]); + StringAssert.Contains(missing.Message, "cost"); + } + + [TestMethod] + public void A_collision_grid_reads_its_flags_from_the_tile_property_the_game_names() + { + var map = Assets().TiledMap("maps/level.tmj"); + var grid = map.CollisionGrid(map.TileLayer("ground"), "collision"); + Assert.AreEqual(16, grid.TileSize); + Assert.AreEqual(TileFlags.Solid, grid.FlagsAt(0, 2), "gid 1: solid"); + Assert.AreEqual(TileFlags.Solid, grid.FlagsAt(1, 2), "a flipped tile keeps its flags"); + Assert.AreEqual(TileFlags.OneWay, grid.FlagsAt(2, 2)); + Assert.AreEqual(TileFlags.Solid | TileFlags.Hazard, grid.FlagsAt(0, 1), "gid 4: 'solid, hazard'"); + Assert.AreEqual(TileFlags.None, grid.FlagsAt(0, 0), "gid 3 has no collision property"); + Assert.AreEqual(TileFlags.None, grid.FlagsAt(1, 0), "an empty cell"); + Assert.AreEqual(TileFlags.Solid, grid.FlagsAt(-1, 0), "outside the map is solid, as every TileGrid"); + + map.TileLayer("ground").Tiles[1, 0] = 1; + Assert.AreEqual(TileFlags.Solid, grid.FlagsAt(1, 0), "the grid reads the layer, so changed cells collide at once"); + + var custom = map.CollisionGrid(map.TileLayer("ground"), t => t.Class == "lava" ? TileFlags.Hazard : TileFlags.None); + Assert.AreEqual(TileFlags.Hazard, custom.FlagsAt(0, 1)); + Assert.AreEqual(TileFlags.None, custom.FlagsAt(0, 2)); + } + + [TestMethod] + public void A_collision_property_naming_an_unknown_flag_is_refused_with_the_tile() + { + var map = Assets().TiledMap("maps/level.tmx"); + var e = Assert.ThrowsExactly(() => map.CollisionGrid(map.TileLayer("ground"), "kind")); + StringAssert.Contains(e.Message, "Tile 0 of tileset 'props'"); + StringAssert.Contains(e.Message, "'treasure'"); + var b = Assert.ThrowsExactly(() => map.CollisionGrid(map.TileLayer("ground"), "bonus")); + StringAssert.Contains(b.Message, "'bonus' is Float"); + } + + [TestMethod] + public void An_infinite_map_is_cropped_to_the_cells_it_uses() + { + var map = Assets().TiledMap("maps/infinite.tmj"); + Assert.AreEqual((4, 3), (map.Width, map.Height), "cells (-1, -1) to (2, 1)"); + Assert.AreEqual((-1, -1), map.Origin); + var ground = map.TileLayer("ground"); + Assert.AreEqual(1, ground.Tiles[0, 0]); + Assert.AreEqual(2, ground.Tiles[3, 2]); + int used = 0; + for (int y = 0; y < 3; y++) for (int x = 0; x < 4; x++) if (ground.Tiles[x, y] != 0) used++; + Assert.AreEqual(2, used); + Assert.AreEqual(new Vector2(16, 16), map.Object(1)!.Position, "objects move with the crop"); + } + + [TestMethod] + public void Maps_that_are_not_orthogonal_are_refused_by_name() + { + var e = Assert.ThrowsExactly(() => Assets().TiledMap("maps/isometric.tmx")); + StringAssert.Contains(e.Message, "maps/isometric.tmx"); + StringAssert.Contains(e.Message, "orthogonal"); + } + + [TestMethod] + public void A_missing_tileset_is_reported_with_the_map_and_the_file() + { + var folder = Directory.CreateTempSubdirectory("tiled-missing"); + try + { + Directory.CreateDirectory(Path.Combine(folder.FullName, "maps")); + File.Copy(Path.Combine(Maps, "maps/level.tmx"), Path.Combine(folder.FullName, "maps/level.tmx")); + var assets = new AssetCache(new HeadlessGraphicsDevice(), folder.FullName); + var e = Assert.ThrowsExactly(() => assets.TiledMap("maps/level.tmx")); + StringAssert.Contains(e.Message, "maps/level.tmx"); + StringAssert.Contains(e.Message, "tilesets/terrain.tsx"); + } + finally { folder.Delete(recursive: true); } + } + + [TestMethod] + public void A_map_loads_once_per_scope_and_from_an_archive() + { + var assets = Assets(); + Assert.AreSame(assets.TiledMap("maps/level.tmj"), assets.TiledMap("maps/level.tmj")); + + using var zip = new MemoryStream(); + using (var archive = new ZipArchive(zip, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var file in Directory.EnumerateFiles(Maps, "*", SearchOption.AllDirectories)) + archive.CreateEntryFromFile(file, Path.GetRelativePath(Maps, file).Replace('\\', '/')); + zip.Position = 0; + using var store = new AssetStore(new ZipContentSource(zip, "maps.zip", leaveOpen: true), new AssetLoaderRegistry().AddTiledMaps()); + var map = store.Get(store.Root.Load(TiledAssets.Kind, "maps/level.tmx")); + Assert.AreEqual(5, map.Object(8)!.Properties.Int("hp"), "the tileset and the template come from the archive too"); + } + + [TestMethod] + public void A_tile_animation_loops_over_its_frames() + { + var tile = Assets().TiledMap("maps/level.tmj").Tile(3)!; + Assert.AreEqual(0.4f, tile.AnimationLength, 1e-6f); + Assert.AreEqual(3, tile.FrameAt(0)); + Assert.AreEqual(3, tile.FrameAt(0.099)); + Assert.AreEqual(4, tile.FrameAt(0.11)); + Assert.AreEqual(4, tile.FrameAt(0.39)); + Assert.AreEqual(3, tile.FrameAt(0.41)); + Assert.AreEqual(1, Assets().TiledMap("maps/level.tmj").Tile(1)!.FrameAt(5), "a tile that does not animate"); + } +} diff --git a/tests/MicroEngine.Tiled.Tests/TiledSpritesTests.cs b/tests/MicroEngine.Tiled.Tests/TiledSpritesTests.cs new file mode 100644 index 00000000..bd5035ac --- /dev/null +++ b/tests/MicroEngine.Tiled.Tests/TiledSpritesTests.cs @@ -0,0 +1,140 @@ +using System.Numerics; +using MicroEngine.Core; +using MicroEngine.Graphics; + +namespace MicroEngine.Tiled.Tests; + +[TestClass] +public sealed class TiledSpritesTests +{ + private static readonly string Maps = Path.Combine(AppContext.BaseDirectory, "Maps"); + private static readonly Color Bg = new(10, 20, 30); + private static readonly Color White = Color.White, Red = new(255, 0, 0), Green = new(0, 255, 0), Blue = new(0, 0, 255), Yellow = new(255, 255, 0), Brown = new(120, 70, 20); + + private static PixelImage Render(string path, Action draw, int width = 64, int height = 48) + { + var device = new HeadlessGraphicsDevice { KeepsPixels = true }; + var assets = new AssetCache(device, Maps); + var sprites = new TiledSprites(assets.TiledMap(path), assets, TextureFilter.Nearest); + var renderer = new HeadlessRenderer(width, height, device); + renderer.Reset(); + renderer.Clear(Bg); + draw(renderer, sprites); + return renderer.Frame!; + } + + private static PixelImage DrawGround(string path, double seconds) => Render(path, (r, s) => + { + foreach (var layer in s.Map.TileLayers) s.Draw(r, layer, null, seconds); + }); + + [TestMethod] + [DataRow("maps/level.tmx")] + [DataRow("maps/level.tmj")] + public void A_tile_object_stands_on_its_position_stretched_to_its_size_and_mirrored(string path) + { + // Object 7: the 16 × 32 chest, mirrored, at (48, 48) in a layer offset by (8, 4): it covers x 56–71, y 20–51. + var f = Render(path, (r, s) => s.Draw(r, s.Map.ObjectLayer("spawns")), 80, 60); + Assert.AreEqual(Brown, f[56, 20]); + Assert.AreEqual(Brown, f[71, 51]); + Assert.AreEqual(Bg, f[55, 30]); + Assert.AreEqual(Bg, f[60, 52]); + Assert.AreEqual(Bg, f[60, 19]); + // The template's block is not a tile object, and the other shapes are data: nothing else is drawn. + Assert.AreEqual(Bg, f[34, 30]); + var everything = Render(path, (r, s) => s.DrawAll(r), 80, 60); + Assert.AreEqual(Brown, everything[60, 50], "DrawAll draws object layers too, above the tiles"); + } + + [TestMethod] + public void A_stretched_and_turned_tile_object_covers_what_Tiled_shows() + { + var f = Render("maps/level.tmj", (r, s) => + { + var chest = s.Map.Object(7)! with { Position = new Vector2(40, 10), Size = new Vector2(32, 16), Rotation = MathF.PI / 2, Orientation = default }; + s.Draw(r, chest); + }, 80, 60); + // Unturned, the 32 × 16 box would rise from its bottom-left (40, 10) to x 72, y -6. A quarter turn clockwise + // about that corner swings it down: x 40 to 56, y 10 to 42. + Assert.AreEqual(Brown, f[41, 11]); + Assert.AreEqual(Brown, f[55, 41]); + Assert.AreEqual(Bg, f[39, 20]); + Assert.AreEqual(Bg, f[57, 20]); + Assert.AreEqual(Bg, f[45, 9]); + Assert.AreEqual(Bg, f[45, 43]); + } + + [TestMethod] + public void The_turned_bounds_hold_what_a_turned_object_covers() + { + var map = new AssetCache(new HeadlessGraphicsDevice(), Maps).TiledMap("maps/level.tmx"); + var chest = map.Object(7)! with { Position = new Vector2(40, 10), Size = new Vector2(32, 16), Rotation = MathF.PI / 2 }; + Assert.AreEqual(new RectF(40, 10, 16, 32), chest.TurnedBounds, "the box the drawing test covers"); + var door = map.Object(1)!; // 16 × 32 at (24, 20), a quarter turn about its top-left + Assert.AreEqual(new RectF(-8, 20, 32, 16), door.TurnedBounds); + Assert.AreEqual(map.Object(2)!.Bounds, map.Object(2)!.TurnedBounds, "an unturned object"); + } + + [TestMethod] + [DataRow("maps/level.tmx")] + [DataRow("maps/level.tmj")] + public void Parallax_shifts_a_layer_by_how_far_the_view_is_from_the_parallax_origin(string path) + { + var map = new AssetCache(new HeadlessGraphicsDevice(), Maps).TiledMap(path); + Assert.AreEqual(new Vector2(32, 24), map.ParallaxOrigin); + var sky = map.Layer("sky"); + Assert.AreEqual(new Vector2(0.5f, 0.25f), sky.Parallax); + Assert.AreEqual(new Vector2(16, 18), map.ParallaxShift(sky, new Vector2(64, 48))); + Assert.AreEqual(Vector2.Zero, map.ParallaxShift(map.TileLayer("ground"), new Vector2(64, 48)), "a layer that moves with the map"); + } + + [TestMethod] + [DataRow("maps/level.tmx")] + [DataRow("maps/level.tmj")] + public void Tiles_are_drawn_from_their_tileset_cells_turned_as_the_map_says(string path) + { + var f = DrawGround(path, 0); + // Row 2: gid 1 (red, white top-left), gid 1 mirrored, gid 2 (green), gid 2 turned half a turn. + Assert.AreEqual(White, f[0, 32]); + Assert.AreEqual(Red, f[15, 47]); + Assert.AreEqual(White, f[31, 32], "mirrored: the white pixel moves to the top-right"); + Assert.AreEqual(Red, f[16, 32]); + Assert.AreEqual(White, f[32, 32]); + Assert.AreEqual(Green, f[47, 47]); + Assert.AreEqual(White, f[63, 47], "half a turn: the white pixel moves to the bottom-right"); + Assert.AreEqual(Green, f[48, 32]); + // Cell (0, 0): gid 3 (blue) mirrored on the diagonal keeps its corner; (0, 1) gid 4 (yellow). + Assert.AreEqual(White, f[0, 0]); + Assert.AreEqual(Blue, f[15, 15]); + Assert.AreEqual(Yellow, f[8, 24]); + // Cell (3, 1): the 16 × 32 chest stands on its cell's bottom, covering the cell above too. + Assert.AreEqual(Brown, f[50, 2]); + Assert.AreEqual(Brown, f[50, 31]); + Assert.AreEqual(Bg, f[20, 4], "empty cells stay empty"); + Assert.AreEqual(Bg, f[36, 4], "the hidden layer's tile is not drawn"); + } + + [TestMethod] + public void Animated_tiles_show_the_frame_of_the_time_given() + { + Assert.AreEqual(Blue, DrawGround("maps/level.tmj", 0.05)[8, 8]); + Assert.AreEqual(Yellow, DrawGround("maps/level.tmj", 0.2)[8, 8]); + Assert.AreEqual(Blue, DrawGround("maps/level.tmj", 0.45)[8, 8]); + } + + [TestMethod] + public void Each_tile_has_a_sprite_on_its_tileset_texture() + { + var device = new HeadlessGraphicsDevice(); + var assets = new AssetCache(device, Maps); + var map = assets.TiledMap("maps/level.tmx"); + var sprites = new TiledSprites(map, assets); + Assert.AreEqual(new RectF(16, 0, 16, 16), sprites[2]!.Source); + Assert.AreSame(sprites[1]!.Texture, sprites[4]!.Texture, "one texture per tileset image"); + Assert.AreSame(assets.Texture("tilesets/terrain.png"), sprites[1]!.Texture, "loaded through the game's assets"); + Assert.AreEqual(new Vector2(0, 16), sprites[5]!.Origin, "a tall tile is drawn upward from its cell"); + Assert.IsNull(sprites[0]); + Assert.IsNull(sprites[99]); + Assert.AreSame(sprites[4], sprites.At(3, 0.2)); + } +} diff --git a/tests/Samples.Tests/Samples.Tests.csproj b/tests/Samples.Tests/Samples.Tests.csproj index 5528484a..20c89ded 100644 --- a/tests/Samples.Tests/Samples.Tests.csproj +++ b/tests/Samples.Tests/Samples.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/Samples.Tests/TiledLevelTests.cs b/tests/Samples.Tests/TiledLevelTests.cs new file mode 100644 index 00000000..b0ee3f67 --- /dev/null +++ b/tests/Samples.Tests/TiledLevelTests.cs @@ -0,0 +1,59 @@ +using MicroEngine.Core; +using MicroEngine.Tiled; +using TiledLevel; + +namespace Samples.Tests; + +/// The Tiled sample: Tiled's own Sticker Knight map, loaded and played headlessly. +[TestClass] +public sealed class TiledLevelTests +{ + private static (TiledLevelGame Game, LevelScene Level) Start() + { + var game = new TiledLevelGame(); + game.InitializeForTest(HeadlessPlatform.For(game.Settings)); + return (game, (LevelScene)game.Scenes.Current!); + } + + private static void Steps(TiledLevelGame game, int count, string? holding = null) + { + if (holding is not null) game.Input.SetForced(holding, true); + for (int i = 0; i < count; i++) game.StepForTest(); + if (holding is not null) game.Input.SetForced(holding, false); + } + + [TestMethod] + public void The_map_saved_by_Tiled_loads_with_its_tileset_templates_and_objects() + { + var (game, level) = Start(); + var map = game.Map; + Assert.AreEqual((79, 45, 32, 32), (map.Width, map.Height, map.TileWidth, map.TileHeight)); + Assert.HasCount(11, map.ObjectLayers.ToList()); + Assert.HasCount(62, map.Tilesets.Single().Tiles, "the collection's images, whatever gaps its ids have"); + Assert.AreEqual(20, level.Solids.Count, "18 static objects and the 2 push blocks of the block template (dynamic, solid in this sample)"); + Assert.AreEqual(6, level.CoinsTotal, "the diamond template's instances"); + Assert.AreEqual(new MicroEngine.Color(0x27, 0xb9, 0x9a), map.Background); + Assert.IsNotNull(game.Sprites[map.Objects.First(o => o.Class == "hero").Gid], "the hero template's tile has a sprite"); + } + + [TestMethod] + public void The_hero_lands_runs_collects_and_jumps() + { + // The tour script's route: run right from the start, jump at update 30, land on the castle's first platform. + var (game, level) = Start(); + Steps(game, 10); + Assert.IsTrue(level.OnGround, "the hero lands on the ground objects"); + Assert.AreEqual(991, level.Hero.Bottom, "the top of the ground tiles: 1087 less their 96-pixel height"); + float x = level.Hero.X; + Steps(game, 20, "right"); + Assert.IsGreaterThan(x + 100, level.Hero.X, "running right"); + Assert.IsLessThan(6, level.CoinsLeft, "the diamonds on the way are collected"); + game.Input.SetForced("right", true); + game.Input.SetForced("confirm", true); + game.StepForTest(); + game.Input.SetForced("confirm", false); + Steps(game, 90, "right"); + Assert.IsTrue(level.OnGround); + Assert.AreEqual(735, level.Hero.Bottom, "on the platform object at y 831, 96 pixels tall"); + } +}