Add MrGameEng.Tilemaps: code-built tile grids rendered through the batcher
CI / build-test (push) Failing after 1m4s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-2
@@ -34,6 +34,7 @@
|
||||
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
|
||||
| `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` свой батчер,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
## Бэклог
|
||||
|
||||
- Physics2D (выбор библиотеки: Aether.Physics2D / своя)
|
||||
- Tilemap (поддержка Tiled)
|
||||
- Tilemaps: загрузка карт Tiled (.tmx) поверх существующего программного API
|
||||
- Particles
|
||||
- UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе
|
||||
- UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.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.DevConsole\MrGameEng.DevConsole.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||
|
||||
@@ -7,6 +7,7 @@ using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
using MrGameEng.Graphics;
|
||||
using MrGameEng.Input;
|
||||
using MrGameEng.Tilemaps;
|
||||
using MrGameEng.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) });
|
||||
SampleLayers.EnsureRegistered(renderer);
|
||||
this.UseTilemaps();
|
||||
|
||||
var playerTexture = assets.Load(GameAssets.Textures.Player);
|
||||
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)),
|
||||
};
|
||||
|
||||
// Тайловая площадка под стартовой зоной: шахматный пол, строится кодом из 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 (см. заголовок окна).
|
||||
var random = new Random(42);
|
||||
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?).");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user