From b3415120c3ddec05e694db2e5010c9ccd465d0e7 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 11 Jun 2026 10:24:47 +0300 Subject: [PATCH] Add MrGameEng.Tilemaps: code-built tile grids rendered through the batcher TileSet maps ids to texture regions with tints (0 = empty), TileGrid is a bounds-checked dense ushort grid, and the Tilemap component places a grid in the world per render layer. UseTilemaps() inserts TilemapRenderSystem before the flush; it submits only the camera-visible cell range (TilemapMath.VisibleCells), so frame cost scales with the screen, not the grid. Demonstrated in the sample as a checkerboard floor; Tiled loading stays on the roadmap on top of this API. --- CLAUDE.md | 8 +- MrGameEng.sln | 30 ++++ docs/architecture.md | 24 ++- docs/roadmap.md | 2 +- .../MrGameEng.Sample/MrGameEng.Sample.csproj | 1 + samples/MrGameEng.Sample/Scenes/MainScene.cs | 22 +++ .../MrGameEng.Tilemaps.csproj | 16 ++ .../SceneTilemapExtensions.cs | 30 ++++ src/MrGameEng.Tilemaps/TileGrid.cs | 59 +++++++ src/MrGameEng.Tilemaps/TileSet.cs | 44 +++++ src/MrGameEng.Tilemaps/Tilemap.cs | 47 ++++++ src/MrGameEng.Tilemaps/TilemapMath.cs | 23 +++ src/MrGameEng.Tilemaps/TilemapRenderSystem.cs | 77 +++++++++ .../MrGameEng.Tilemaps.Tests.csproj | 19 +++ .../MrGameEng.Tilemaps.Tests/TilemapTests.cs | 152 ++++++++++++++++++ 15 files changed, 548 insertions(+), 6 deletions(-) create mode 100644 src/MrGameEng.Tilemaps/MrGameEng.Tilemaps.csproj create mode 100644 src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs create mode 100644 src/MrGameEng.Tilemaps/TileGrid.cs create mode 100644 src/MrGameEng.Tilemaps/TileSet.cs create mode 100644 src/MrGameEng.Tilemaps/Tilemap.cs create mode 100644 src/MrGameEng.Tilemaps/TilemapMath.cs create mode 100644 src/MrGameEng.Tilemaps/TilemapRenderSystem.cs create mode 100644 tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj create mode 100644 tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 56809b2..d87af13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,14 @@ Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles), `Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`), -`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole` -(in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad). +`Tilemaps` (code-built tile grids rendered through the batcher; `scene.UseTilemaps()` +after `UseRenderer2D()`), `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), +`DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad). Dependency rule: every module may depend only on `Core`; `Core` depends only on MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer. Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases` -depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration). +depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration); +`Tilemaps` depends on `Graphics` (regions, layers, renderer). ## Commands diff --git a/MrGameEng.sln b/MrGameEng.sln index 2cb56c4..50ca30d 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AtlasTool", "tool EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps", "src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj", "{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps.Tests", "tests\MrGameEng.Tilemaps.Tests\MrGameEng.Tilemaps.Tests.csproj", "{10B318BB-BB00-4A9D-8AF3-D36C570B6286}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -259,6 +263,30 @@ Global {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.Build.0 = Release|Any CPU {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.ActiveCfg = Release|Any CPU {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.Build.0 = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.Build.0 = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.Build.0 = Debug|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.Build.0 = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.ActiveCfg = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.Build.0 = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.ActiveCfg = Release|Any CPU + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.Build.0 = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.ActiveCfg = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.Build.0 = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.ActiveCfg = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.Build.0 = Debug|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.Build.0 = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.ActiveCfg = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.Build.0 = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.ActiveCfg = Release|Any CPU + {10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -281,5 +309,7 @@ Global {B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {1951D50B-122A-45B5-9356-F186A3CBC974} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {43FCB103-4EBA-464D-BF39-9F1FA83E52E9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {10B318BB-BB00-4A9D-8AF3-D36C570B6286} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index ffa34d3..368e12d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,7 @@ | `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef` | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов | | `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` | +| `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг | | `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение | @@ -51,9 +52,10 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`. -Документированное исключение: `MrGameEng.Atlases` зависит от `Graphics` +Документированные исключения: `MrGameEng.Atlases` зависит от `Graphics` (выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) — -атлас по своей природе склейка этих двух областей. +атлас по своей природе склейка этих двух областей; `MrGameEng.Tilemaps` зависит от +`Graphics` (рисует регионы через рендерер и слои). `MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0), он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит @@ -163,6 +165,24 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто и отдаёт регионы: `atlas.GetRegion("Things/Pawn/Animal/Fox")` → `Texture2DRegion`, готовый для `Sprite`. +## Тайловые карты + +`MrGameEng.Tilemaps` — тайловые карты, создаваемые **кодом** (загрузка Tiled — в бэклоге): + +- `TileSet` — словарь тайлов: `Add(region, tint?)` возвращает id; id 0 зарезервирован + под «пусто». Регион + тинт позволяют строить тайлсеты и из текстур атласа, + и из тонированной белой текстуры. +- `TileGrid` — плотная сетка `ushort`-id с проверкой границ; заполняется генератором + мира, мутируется в рантайме (изменение видно со следующего кадра). +- Компонент `Tilemap` (`struct : IComponent`): грид + тайлсет + `Origin`, `TileSize`, + слой, `Depth`, общий тинт карты. Обычная сущность — карт может быть несколько + (земля, декор поверх). +- `scene.UseTilemaps()` (после `UseRenderer2D()`) вставляет `TilemapRenderSystem` + в Draw-фазу перед flush. Система считает видимый диапазон клеток по cull-rect + камеры (`TilemapMath.VisibleCells`) и сабмитит только его: стоимость кадра зависит + от экрана, а не от размера грида. Тайлы батчатся со спрайтами по обычному порядку + слой → depth → текстура: пол из одной текстуры атласа — один draw call. + ## Рендеринг `SpriteBatch` в движке **не используется** — в `MrGameEng.Graphics` свой батчер, diff --git a/docs/roadmap.md b/docs/roadmap.md index 1123e81..05ed5ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ ## Бэклог - Physics2D (выбор библиотеки: Aether.Physics2D / своя) -- Tilemap (поддержка Tiled) +- Tilemaps: загрузка карт Tiled (.tmx) поверх существующего программного API - Particles - UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе - UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj index 82fd157..c75a03f 100644 --- a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj +++ b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj @@ -12,6 +12,7 @@ + + + + net8.0 + + + + + + + + + + + + diff --git a/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs b/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs new file mode 100644 index 0000000..e52cb79 --- /dev/null +++ b/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs @@ -0,0 +1,30 @@ +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace MrGameEng.Tilemaps; + +/// Wires the tilemaps module into a . +public static class SceneTilemapExtensions +{ + /// + /// Adds to the scene's draw phase, right before the + /// renderer flush. Call from OnLoad after UseRenderer2D(). + /// + public static void UseTilemaps(this Scene scene) + { + var renderer = scene.Context.Services.GetOrDefault() + ?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first."); + + var systems = scene.DrawSystems.ChildSystems; + for (var i = 0; i < systems.Count; i++) + { + if (systems[i] is RenderFlushSystem) + { + scene.DrawSystems.Insert(i, new TilemapRenderSystem(renderer)); + return; + } + } + + throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?)."); + } +} diff --git a/src/MrGameEng.Tilemaps/TileGrid.cs b/src/MrGameEng.Tilemaps/TileGrid.cs new file mode 100644 index 0000000..08b9818 --- /dev/null +++ b/src/MrGameEng.Tilemaps/TileGrid.cs @@ -0,0 +1,59 @@ +namespace MrGameEng.Tilemaps; + +/// +/// Dense rectangular grid of tile ids (see ; 0 = empty). +/// Plain data with bounds-checked access — fill it from worldgen code, mutate at runtime. +/// +public sealed class TileGrid +{ + private readonly ushort[] _cells; + + /// Grid width in cells. + public int Width { get; } + + /// Grid height in cells. + public int Height { get; } + + /// Creates a grid filled with the empty tile. + public TileGrid(int width, int height) + { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); + Width = width; + Height = height; + _cells = new ushort[width * height]; + } + + /// Tile id at the given cell. + public ushort this[int x, int y] + { + get + { + CheckBounds(x, y); + return _cells[y * Width + x]; + } + set + { + CheckBounds(x, y); + _cells[y * Width + x] = value; + } + } + + /// True when the cell lies inside the grid. + public bool Contains(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height; + + /// Sets every cell to . + public void Fill(ushort id) => Array.Fill(_cells, id); + + /// Unchecked read used by the render system after range clamping. + internal ushort UnsafeGet(int x, int y) => _cells[y * Width + x]; + + private void CheckBounds(int x, int y) + { + if (!Contains(x, y)) + { + throw new ArgumentOutOfRangeException( + nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid."); + } + } +} diff --git a/src/MrGameEng.Tilemaps/TileSet.cs b/src/MrGameEng.Tilemaps/TileSet.cs new file mode 100644 index 0000000..543b6cf --- /dev/null +++ b/src/MrGameEng.Tilemaps/TileSet.cs @@ -0,0 +1,44 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace MrGameEng.Tilemaps; + +/// One tile kind: a texture region plus a tint multiplier. +/// Texture region the tile is drawn with. +/// Tint, multiplied with the texture. White = unmodified. +public readonly record struct TileDef(Texture2DRegion Region, Color Color) +{ + /// Creates an untinted tile. + public TileDef(Texture2DRegion region) + : this(region, Color.White) + { + } +} + +/// +/// Maps tile ids to s. Built in code: every returns +/// the id to store in a . Id 0 is reserved for "empty" (nothing drawn). +/// +public sealed class TileSet +{ + private readonly List _tiles = [default]; + + /// Number of defined tiles including the reserved empty tile 0. + public int Count => _tiles.Count; + + /// The tile definition for . Id 0 has no region. + public TileDef this[int id] => _tiles[id]; + + /// Defines a tile and returns its id (1, 2, …). + public ushort Add(Texture2DRegion region, Color? color = null) + { + ArgumentNullException.ThrowIfNull(region); + if (_tiles.Count > ushort.MaxValue) + { + throw new InvalidOperationException("A TileSet holds at most 65535 tiles."); + } + + _tiles.Add(new TileDef(region, color ?? Color.White)); + return (ushort)(_tiles.Count - 1); + } +} diff --git a/src/MrGameEng.Tilemaps/Tilemap.cs b/src/MrGameEng.Tilemaps/Tilemap.cs new file mode 100644 index 0000000..fe13bcc --- /dev/null +++ b/src/MrGameEng.Tilemaps/Tilemap.cs @@ -0,0 +1,47 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace MrGameEng.Tilemaps; + +/// +/// Tilemap component: a of ids drawn with a . +/// Cell (0,0) sits at , cells are world units square. +/// Only the cells visible through the camera are submitted each frame, so grids can be large. +/// Create via the constructor — the struct default has no grid and draws nothing. +/// +public struct Tilemap : IComponent +{ + /// Tile definitions; ids in the grid index into it. + public TileSet? TileSet; + + /// The cells. Mutating ids takes effect next frame. + public TileGrid? Grid; + + /// World position of the top-left corner of cell (0,0). + public Vector2 Origin; + + /// Cell size in world units. + public float TileSize; + + /// Render layer of the whole map. + public LayerId Layer; + + /// Draw order within the layer (smaller = drawn first / behind). + public float Depth; + + /// Tint multiplied into every tile on top of its . + public Color Color; + + /// Creates a tilemap at world origin. + public Tilemap(TileGrid grid, TileSet tileSet, float tileSize, LayerId layer = default) + { + Grid = grid; + TileSet = tileSet; + TileSize = tileSize; + Layer = layer; + Origin = Vector2.Zero; + Depth = 0f; + Color = Color.White; + } +} diff --git a/src/MrGameEng.Tilemaps/TilemapMath.cs b/src/MrGameEng.Tilemaps/TilemapMath.cs new file mode 100644 index 0000000..0394520 --- /dev/null +++ b/src/MrGameEng.Tilemaps/TilemapMath.cs @@ -0,0 +1,23 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace MrGameEng.Tilemaps; + +/// Pure cell-range math for tilemap rendering. Y axis points down. +public static class TilemapMath +{ + /// + /// Computes the inclusive cell range of a grid that intersects . + /// Returns false when the map is entirely outside the rectangle. + /// + public static bool VisibleCells( + in RectF cullRect, Vector2 origin, float tileSize, int width, int height, + out int x0, out int y0, out int x1, out int y1) + { + x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize)); + y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize)); + x1 = Math.Min(width - 1, (int)MathF.Floor((cullRect.Right - origin.X) / tileSize)); + y1 = Math.Min(height - 1, (int)MathF.Floor((cullRect.Bottom - origin.Y) / tileSize)); + return x0 <= x1 && y0 <= y1; + } +} diff --git a/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs b/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs new file mode 100644 index 0000000..89b7afb --- /dev/null +++ b/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs @@ -0,0 +1,77 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace MrGameEng.Tilemaps; + +/// +/// Submits the camera-visible cells of every entity to the renderer. +/// Cells outside the camera are never touched, so per-frame cost scales with the screen, +/// not with the grid. Tiles batch with sprites by the usual layer → depth → texture order. +/// +public sealed class TilemapRenderSystem : QuerySystem +{ + private readonly Renderer2D _renderer; + + /// Creates the system for . + public TilemapRenderSystem(Renderer2D renderer) => _renderer = renderer; + + /// + protected override void OnUpdate() + { + var cullRect = _renderer.Camera.CullRect; + foreach (var (maps, _) in Query.Chunks) + { + foreach (ref readonly var map in maps.Span) + { + if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f) + { + continue; + } + + var screenSpace = _renderer.Layers[map.Layer].Space == LayerSpace.Screen; + if (screenSpace) + { + // Screen-space слои не куллятся камерой — рисуем весь грид. + SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1); + } + else if (TilemapMath.VisibleCells( + in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height, + out var x0, out var y0, out var x1, out var y1)) + { + SubmitRange(in map, grid, tileSet, x0, y0, x1, y1); + } + } + } + } + + private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1) + { + for (var y = y0; y <= y1; y++) + { + for (var x = x0; x <= x1; x++) + { + var id = grid.UnsafeGet(x, y); + if (id == 0) + { + continue; + } + + var def = tileSet[id]; + var region = def.Region; + var transform = new Transform2D( + map.Origin + new Vector2(x, y) * map.TileSize, + scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height)); + var sprite = new Sprite(region, map.Layer) + { + Color = map.Color == Color.White + ? def.Color + : new Color(def.Color.ToVector4() * map.Color.ToVector4()), + Depth = map.Depth, + }; + _renderer.Submit(in transform, in sprite); + } + } + } +} diff --git a/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj b/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj new file mode 100644 index 0000000..e2f45c1 --- /dev/null +++ b/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + Exe + false + + + + + + + + + + + + + diff --git a/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs b/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs new file mode 100644 index 0000000..c5ffa5c --- /dev/null +++ b/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs @@ -0,0 +1,152 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Graphics; +using Xunit; + +namespace MrGameEng.Tilemaps.Tests; + +public class TileGridTests +{ + [Fact] + public void NewGrid_IsEmpty() + { + var grid = new TileGrid(4, 3); + + Assert.Equal(4, grid.Width); + Assert.Equal(3, grid.Height); + for (var y = 0; y < 3; y++) + { + for (var x = 0; x < 4; x++) + { + Assert.Equal(0, grid[x, y]); + } + } + } + + [Fact] + public void SetGet_RoundtripsPerCell() + { + var grid = new TileGrid(3, 3); + + grid[2, 1] = 7; + + Assert.Equal(7, grid[2, 1]); + Assert.Equal(0, grid[1, 2]); + } + + [Fact] + public void Fill_SetsEveryCell() + { + var grid = new TileGrid(5, 5); + + grid.Fill(3); + + Assert.Equal(3, grid[0, 0]); + Assert.Equal(3, grid[4, 4]); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, -1)] + [InlineData(3, 0)] + [InlineData(0, 2)] + public void OutOfBounds_Throws(int x, int y) + { + var grid = new TileGrid(3, 2); + + Assert.False(grid.Contains(x, y)); + Assert.Throws(() => grid[x, y]); + Assert.Throws(() => grid[x, y] = 1); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(-1, 1)] + public void InvalidSize_Throws(int width, int height) + { + Assert.Throws(() => new TileGrid(width, height)); + } +} + +public class TileSetTests +{ + // Texture2D == null допустим в headless-тестах (см. Texture2DRegion). + private static Texture2DRegion Region(int size = 16) => + new(null!, new Rectangle(0, 0, size, size)); + + [Fact] + public void Add_ReturnsSequentialIds_StartingAtOne() + { + var tiles = new TileSet(); + + var first = tiles.Add(Region()); + var second = tiles.Add(Region(), Color.Red); + + Assert.Equal(1, first); + Assert.Equal(2, second); + Assert.Equal(3, tiles.Count); + Assert.Equal(Color.White, tiles[first].Color); + Assert.Equal(Color.Red, tiles[second].Color); + } + + [Fact] + public void EmptyTileZero_HasNoRegion() + { + var tiles = new TileSet(); + + Assert.Null(tiles[0].Region); + } +} + +public class TilemapMathTests +{ + [Fact] + public void CameraInsideMap_ReturnsClampedRange() + { + var cull = new RectF(35f, 18f, 40f, 30f); // правый край 75, нижний 48 + + var visible = TilemapMath.VisibleCells(in cull, Vector2.Zero, 16f, 10, 10, + out var x0, out var y0, out var x1, out var y1); + + Assert.True(visible); + Assert.Equal((2, 1, 4, 3), (x0, y0, x1, y1)); + } + + [Fact] + public void MapOffsetByOrigin_ShiftsRange() + { + var cull = new RectF(0f, 0f, 64f, 64f); + + var visible = TilemapMath.VisibleCells(in cull, new Vector2(-32f, -32f), 16f, 100, 100, + out var x0, out var y0, out var x1, out var y1); + + Assert.True(visible); + Assert.Equal((2, 2, 6, 6), (x0, y0, x1, y1)); + } + + [Fact] + public void CameraLargerThanMap_ClampsToWholeGrid() + { + var cull = new RectF(-1000f, -1000f, 5000f, 5000f); + + var visible = TilemapMath.VisibleCells(in cull, Vector2.Zero, 16f, 8, 6, + out var x0, out var y0, out var x1, out var y1); + + Assert.True(visible); + Assert.Equal((0, 0, 7, 5), (x0, y0, x1, y1)); + } + + [Theory] + [InlineData(200f, 0f)] // справа от карты + [InlineData(-200f, 0f)] // слева + [InlineData(0f, 200f)] // ниже + public void CameraOutsideMap_ReturnsFalse(float offsetX, float offsetY) + { + var cull = new RectF(offsetX, offsetY, 100f, 100f); + + var visible = TilemapMath.VisibleCells(in cull, new Vector2(-150f, -150f), 16f, 8, 8, + out _, out _, out _, out _); + + Assert.False(visible); + } +}