Add MrGameEng.Tilemaps: code-built tile grids rendered through the batcher
CI / build-test (push) Failing after 1m4s

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.
This commit is contained in:
Leonid Pershin
2026-06-11 10:24:47 +03:00
parent b318d1e795
commit b3415120c3
15 changed files with 548 additions and 6 deletions
+5 -3
View File
@@ -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 renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles), pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
`Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`), `Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`),
`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole` `Tilemaps` (code-built tile grids rendered through the batcher; `scene.UseTilemaps()`
(in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad). 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 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. MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases` 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 ## Commands
+30
View File
@@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AtlasTool", "tool
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -281,5 +309,7 @@ Global
{B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{1951D50B-122A-45B5-9356-F186A3CBC974} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {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 EndGlobalSection
EndGlobal EndGlobal
+22 -2
View File
@@ -34,6 +34,7 @@
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` | | `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` | | `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` |
| `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение | | `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
@@ -51,9 +52,10 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только
от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`. от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`.
Документированное исключение: `MrGameEng.Atlases` зависит от `Graphics` Документированные исключения: `MrGameEng.Atlases` зависит от `Graphics`
(выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) — (выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) —
атлас по своей природе склейка этих двух областей. атлас по своей природе склейка этих двух областей; `MrGameEng.Tilemaps` зависит от
`Graphics` (рисует регионы через рендерер и слои).
`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0), `MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0),
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
@@ -163,6 +165,24 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
и отдаёт регионы: `atlas.GetRegion("Things/Pawn/Animal/Fox")` → `Texture2DRegion`, и отдаёт регионы: `atlas.GetRegion("Things/Pawn/Animal/Fox")` → `Texture2DRegion`,
готовый для `Sprite`. готовый для `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` свой батчер, `SpriteBatch` в движке **не используется** — в `MrGameEng.Graphics` свой батчер,
+1 -1
View File
@@ -16,7 +16,7 @@
## Бэклог ## Бэклог
- Physics2D (выбор библиотеки: Aether.Physics2D / своя) - Physics2D (выбор библиотеки: Aether.Physics2D / своя)
- Tilemap (поддержка Tiled) - Tilemaps: загрузка карт Tiled (.tmx) поверх существующего программного API
- Particles - Particles
- UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе - UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе
- UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам - UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам
@@ -12,6 +12,7 @@
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" <ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
@@ -7,6 +7,7 @@ using MrGameEng.Core;
using MrGameEng.DevConsole; using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.Tilemaps;
using MrGameEng.UI; using MrGameEng.UI;
using Myra.Graphics2D.UI; using Myra.Graphics2D.UI;
@@ -32,6 +33,7 @@ public sealed class MainScene : Scene
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }); var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
SampleLayers.EnsureRegistered(renderer); SampleLayers.EnsureRegistered(renderer);
this.UseTilemaps();
var playerTexture = assets.Load(GameAssets.Textures.Player); var playerTexture = assets.Load(GameAssets.Textures.Player);
var shapesTexture = assets.Load(GameAssets.Textures.Shapes); var shapesTexture = assets.Load(GameAssets.Textures.Shapes);
@@ -50,6 +52,26 @@ public sealed class MainScene : Scene
new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)), new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)),
}; };
// Тайловая площадка под стартовой зоной: шахматный пол, строится кодом из TileSet/TileGrid.
// Видимые клетки сабмитятся каждый кадр; Depth -10 кладёт пол под декорации.
var floorTiles = new TileSet();
var floorA = floorTiles.Add(shapeRegions[0], new Color(70, 75, 95));
var floorB = floorTiles.Add(shapeRegions[2], new Color(55, 60, 78));
var floorGrid = new TileGrid(24, 14);
for (var y = 0; y < floorGrid.Height; y++)
{
for (var x = 0; x < floorGrid.Width; x++)
{
floorGrid[x, y] = (x + y) % 2 == 0 ? floorA : floorB;
}
}
Store.CreateEntity(new Tilemap(floorGrid, floorTiles, tileSize: 48f)
{
Origin = new Vector2(-24 * 24f, -14 * 24f),
Depth = -10f,
});
// Декорации по всему миру: уезжаешь камерой — попадают под culling (см. заголовок окна). // Декорации по всему миру: уезжаешь камерой — попадают под culling (см. заголовок окна).
var random = new Random(42); var random = new Random(42);
for (var i = 0; i < DecorCount; i++) for (var i = 0; i < DecorCount; i++)
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Tilemaps.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>Wires the tilemaps module into a <see cref="Scene"/>.</summary>
public static class SceneTilemapExtensions
{
/// <summary>
/// Adds <see cref="TilemapRenderSystem"/> to the scene's draw phase, right before the
/// renderer flush. Call from <c>OnLoad</c> after <c>UseRenderer2D()</c>.
/// </summary>
public static void UseTilemaps(this Scene scene)
{
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
?? 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?).");
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace MrGameEng.Tilemaps;
/// <summary>
/// Dense rectangular grid of tile ids (see <see cref="TileSet"/>; 0 = empty).
/// Plain data with bounds-checked access — fill it from worldgen code, mutate at runtime.
/// </summary>
public sealed class TileGrid
{
private readonly ushort[] _cells;
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>Creates a grid filled with the empty tile.</summary>
public TileGrid(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
Width = width;
Height = height;
_cells = new ushort[width * height];
}
/// <summary>Tile id at the given cell.</summary>
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;
}
}
/// <summary>True when the cell lies inside the grid.</summary>
public bool Contains(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
/// <summary>Sets every cell to <paramref name="id"/>.</summary>
public void Fill(ushort id) => Array.Fill(_cells, id);
/// <summary>Unchecked read used by the render system after range clamping.</summary>
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.");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>One tile kind: a texture region plus a tint multiplier.</summary>
/// <param name="Region">Texture region the tile is drawn with.</param>
/// <param name="Color">Tint, multiplied with the texture. White = unmodified.</param>
public readonly record struct TileDef(Texture2DRegion Region, Color Color)
{
/// <summary>Creates an untinted tile.</summary>
public TileDef(Texture2DRegion region)
: this(region, Color.White)
{
}
}
/// <summary>
/// Maps tile ids to <see cref="TileDef"/>s. Built in code: every <see cref="Add"/> returns
/// the id to store in a <see cref="TileGrid"/>. Id 0 is reserved for "empty" (nothing drawn).
/// </summary>
public sealed class TileSet
{
private readonly List<TileDef> _tiles = [default];
/// <summary>Number of defined tiles including the reserved empty tile 0.</summary>
public int Count => _tiles.Count;
/// <summary>The tile definition for <paramref name="id"/>. Id 0 has no region.</summary>
public TileDef this[int id] => _tiles[id];
/// <summary>Defines a tile and returns its id (1, 2, …).</summary>
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);
}
}
+47
View File
@@ -0,0 +1,47 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>
/// Tilemap component: a <see cref="TileGrid"/> of ids drawn with a <see cref="TileSet"/>.
/// Cell (0,0) sits at <see cref="Origin"/>, cells are <see cref="TileSize"/> 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.
/// </summary>
public struct Tilemap : IComponent
{
/// <summary>Tile definitions; ids in the grid index into it.</summary>
public TileSet? TileSet;
/// <summary>The cells. Mutating ids takes effect next frame.</summary>
public TileGrid? Grid;
/// <summary>World position of the top-left corner of cell (0,0).</summary>
public Vector2 Origin;
/// <summary>Cell size in world units.</summary>
public float TileSize;
/// <summary>Render layer of the whole map.</summary>
public LayerId Layer;
/// <summary>Draw order within the layer (smaller = drawn first / behind).</summary>
public float Depth;
/// <summary>Tint multiplied into every tile on top of its <see cref="TileDef.Color"/>.</summary>
public Color Color;
/// <summary>Creates a tilemap at world origin.</summary>
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;
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>Pure cell-range math for tilemap rendering. Y axis points down.</summary>
public static class TilemapMath
{
/// <summary>
/// Computes the inclusive cell range of a grid that intersects <paramref name="cullRect"/>.
/// Returns false when the map is entirely outside the rectangle.
/// </summary>
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;
}
}
@@ -0,0 +1,77 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>
/// Submits the camera-visible cells of every <see cref="Tilemap"/> 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.
/// </summary>
public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
{
private readonly Renderer2D _renderer;
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public TilemapRenderSystem(Renderer2D renderer) => _renderer = renderer;
/// <inheritdoc />
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);
}
}
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
</ItemGroup>
</Project>
@@ -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<ArgumentOutOfRangeException>(() => grid[x, y]);
Assert.Throws<ArgumentOutOfRangeException>(() => grid[x, y] = 1);
}
[Theory]
[InlineData(0, 0)]
[InlineData(-1, 1)]
public void InvalidSize_Throws(int width, int height)
{
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
}
}