TileMap component for a 2D/3D tilemap system. Stores tile layers, tileset references and per-tile flags, converts between world and cell coordinates, manages layer add/remove/resize/shrink, keeps caches of filled cells, and drives a TilemapRenderObject for streaming/culling.
using System;
using System.Text.Json.Serialization;
namespace Saandy.Tilemapper;
public sealed class TileMap : Component, Component.ExecuteInEditor
{
public struct Tile : IEquatable<Tile>
{
public ushort TilesetId { get; set; }
public ushort TileId { get; set; }
// These are per-painted-cell flags.
// A tile can exist in the map for autotile mask rules while still being hidden
// and non-colliding, for example the 2x2 edge brush isolated placeholder.
public bool IsVisible { get; set; }
public bool UseCollider { get; set; }
public bool IsEmpty => TilesetId == 0;
public Tile( ushort tilesetId, ushort tileId, bool isVisible = true, bool useCollider = true )
{
TilesetId = tilesetId;
TileId = tileId;
IsVisible = tilesetId != 0 && isVisible;
UseCollider = tilesetId != 0 && useCollider;
}
public bool ShouldRender => !IsEmpty && IsVisible;
public bool ShouldCollide => !IsEmpty && UseCollider;
public bool Equals( Tile other )
{
return TilesetId == other.TilesetId
&& TileId == other.TileId
&& IsVisible == other.IsVisible
&& UseCollider == other.UseCollider;
}
public override bool Equals( object obj )
{
return obj is Tile other && Equals( other );
}
public override int GetHashCode()
{
return HashCode.Combine( TilesetId, TileId, IsVisible, UseCollider );
}
public static bool operator ==( Tile left, Tile right )
{
return left.Equals( right );
}
public static bool operator !=( Tile left, Tile right )
{
return !left.Equals( right );
}
}
public sealed class TileLayer
{
[Property]
public string Name { get; set; } = "Layer";
[Property, Title( "Visible" )]
public bool IsVisible { get; set; } = true;
[Property, Title( "Collisions" )]
public bool CollisionsEnabled { get; set; } = true;
[Property, Hide]
public List<Tile> Tiles { get; set; } = new();
[Property, Hide]
public int Width { get; set; } = 1;
[Property, Hide]
public int Height { get; set; } = 1;
[Property, Hide]
public Vector2Int Origin { get; set; }
[JsonIgnore]
public List<Vector2Int> FilledCells { get; set; } = new();
[JsonIgnore]
public bool FilledCellsDirty { get; set; } = true;
}
public static bool EditorPainterActive { get; set; }
[Property]
public int DefaultTileValue { get; set; } = 1;
[Property]
public float TileSize { get; set; } = 24f;
[Property]
public float ChunkSize { get; set; } = 32f;
[Property, Title( "Axis" )]
public TileMapAxis Axis { get; set; } = TileMapAxis.XZ;
[Property, Title( "Layer Spacing" )]
public float LayerSpacing { get; set; } = 0.05f;
[Property, Hide]
private List<TilesetResource> _tilesets { get; set; } = new();
[Property, Hide]
private List<Guid> _tilesetGuids { get; set; } = new();
// Resource paths are not the primary identity anymore.
// They are only used as a disambiguation/repair hint when duplicate GUIDs exist
// because a tileset asset was copied with its serialized GUID.
[Property, Hide]
private List<string> _tilesetResourcePaths { get; set; } = new();
[Property, Hide]
private List<TileLayer> _layers { get; set; } = new();
// Legacy single-layer storage. This is only used to migrate old maps into layer 0.
[Property, Hide]
private List<Tile> _tiles { get; set; } = new();
private bool _tilesDirty = true;
private bool _wasPaintedLastFrame = false;
[Property, Hide] public int ActiveLayerIndex { get; private set; }
[Property, Hide] public int Width { get; private set; } = 1;
[Property, Hide] public int Height { get; private set; } = 1;
[Property, Hide] public Vector2Int Origin { get; private set; }
public IReadOnlyList<TileLayer> Layers
{
get
{
EnsureLayers();
return _layers;
}
}
public int LayerCount
{
get
{
EnsureLayers();
return _layers.Count;
}
}
public IReadOnlyList<Tile> Tiles => ActiveLayer?.Tiles is IReadOnlyList<Tile> tiles ? tiles : Array.Empty<Tile>();
public List<Vector2Int> FilledCells => ActiveLayer?.FilledCells ?? new List<Vector2Int>();
public Vector2Int Size => new( Width, Height );
public int Revision { get; private set; }
public TilemapRenderObject Renderer { get; private set; }
public TileLayer ActiveLayer
{
get
{
EnsureLayers();
return _layers.Count == 0 ? null : _layers[Math.Clamp( ActiveLayerIndex, 0, _layers.Count - 1 )];
}
}
public static TileMap GetOrCreate( Scene scene )
{
var tilemap = scene.GetAllComponents<TileMap>().FirstOrDefault();
if ( tilemap != null )
return tilemap;
var gameObject = new GameObject( "Tilemap" );
return gameObject.AddComponent<TileMap>();
}
[Button( "Clear" )]
public void Clear()
{
_tiles?.Clear();
_tilesets?.Clear();
_tilesetGuids?.Clear();
_tilesetResourcePaths?.Clear();
_layers?.Clear();
ActiveLayerIndex = 0;
EnsureLayers();
MarkTilemapChanged();
}
protected override void OnAwake()
{
base.OnAwake();
EnsureLayers();
EnsureRenderer();
MarkAllLayerFilledCellsDirty();
Revision++;
}
protected override void OnEnabled()
{
base.OnEnabled();
EnsureLayers();
EnsureRenderer();
MarkAllLayerFilledCellsDirty();
Revision++;
UpdateRenderer();
}
private void EnsureRenderer()
{
if ( Renderer != null ) return;
using ( Scene.Push() )
{
Renderer = new TilemapRenderObject( Scene.SceneWorld );
}
SyncRendererSettings();
MarkAllLayerFilledCellsDirty();
Revision++;
}
protected override void OnUpdate()
{
base.OnUpdate();
EnsureLayers();
EnsureRenderer();
UpdateRenderer();
if ( _tilesDirty )
{
Scene.RunEvent<ITilemapSceneEvent>( x => x.OnTilemapChanged() );
}
if ( !_wasPaintedLastFrame )
{
if ( _tilesDirty )
{
Scene.RunEvent<ITilemapSceneEvent>( x => x.OnTilemapStable() );
_tilesDirty = false;
}
}
_wasPaintedLastFrame = false;
}
protected override void OnDisabled()
{
base.OnDisabled();
Renderer?.Disable();
Renderer = null;
}
protected override void DrawGizmos()
{
base.DrawGizmos();
if ( Renderer == null )
return;
Gizmo.Draw.Color = Color.Yellow.WithAlpha( 0.15f );
Gizmo.Transform = Game.ActiveScene.WorldTransform;
foreach ( var pair in Renderer.ActiveChunks )
{
Gizmo.Draw.LineBBox( pair.Value.Bounds );
}
}
private void UpdateRenderer()
{
UpdateRendererFromCamera( GetDefaultRenderCamera() );
}
public void ForceRendererUpdate( CameraComponent camera )
{
EnsureRenderer();
UpdateRendererFromCamera( camera ?? GetDefaultRenderCamera() );
}
private CameraComponent GetDefaultRenderCamera()
{
// In edit mode, Scene.Camera is the game camera.
// Application.Editor.Camera is the editor viewport camera and exists even when
// the Tilemap Painter tool is not selected.
if ( !Game.IsPlaying )
{
var editorCamera = Application.Editor?.Camera;
if ( editorCamera != null && editorCamera.IsValid() )
return editorCamera;
}
return Scene.Camera;
}
private void UpdateRendererFromCamera( CameraComponent camera )
{
if ( Renderer == null )
return;
Renderer.SetTilemap( this );
SyncRendererSettings();
if ( camera == null || !camera.IsValid() )
return;
Renderer.CullingCamera = camera;
Renderer.UpdateStreaming( camera.WorldPosition );
}
private void SyncRendererSettings()
{
if ( Renderer == null )
return;
Renderer.TileSize = System.Math.Max( TileSize, 0.0001f );
Renderer.ChunkResolution = System.Math.Max( 1, (int)ChunkSize );
Renderer.ChunkSize = Renderer.ChunkResolution * Renderer.TileSize;
Renderer.RenderRadius = System.Math.Max( 1, Renderer.RenderRadius );
}
public bool IsSameTileset( int xa, int ya, int xb, int yb )
{
if ( !TryGetTile( xa, ya, out var tileA ) )
return false;
if ( !TryGetTile( xb, yb, out var tileB ) )
return false;
return IsSameTilesetId( tileA.TilesetId, tileB.TilesetId );
}
public bool IsSameTilesetId( ushort tilesetIdA, ushort tilesetIdB )
{
if ( tilesetIdA == 0 || tilesetIdB == 0 )
return false;
if ( tilesetIdA == tilesetIdB )
return true;
return IsSameTilesetResource( GetTileset( tilesetIdA ), GetTileset( tilesetIdB ) );
}
public bool TryGetTile( int x, int y, out Tile tile )
{
return TryGetTile( ActiveLayerIndex, x, y, out tile );
}
public bool TryGetTile( int layerIndex, int x, int y, out Tile tile )
{
TileLayer layer = GetLayer( layerIndex );
if ( layer == null || !ContainsCell( layer, x, y ) )
{
tile = default;
return false;
}
tile = layer.Tiles[GetIndex( layer, x, y )];
return true;
}
public void SetTile( int x, int y, Tile tile )
{
TileLayer layer = ActiveLayer;
if ( layer == null )
return;
if ( TryGetTile( ActiveLayerIndex, x, y, out Tile currentValue ) && currentValue == tile )
return;
EnsureCell( layer, x, y );
layer.Tiles[GetIndex( layer, x, y )] = tile;
LayerTilesChanged( layer, painted: true );
ShrinkToContents( layer );
}
public void ClearTile( int x, int y )
{
TileLayer layer = ActiveLayer;
if ( layer == null )
return;
if ( !TryGetTile( ActiveLayerIndex, x, y, out Tile currentValue ) || currentValue.IsEmpty )
return;
layer.Tiles[GetIndex( layer, x, y )] = default;
LayerTilesChanged( layer, painted: true );
ShrinkToContents( layer );
}
public ushort GetTilesetIdAt( int x, int y )
{
if ( !TryGetTile( x, y, out var tile ) )
return 0;
return tile.TilesetId;
}
public void SetTiles(
IEnumerable<Vector2Int> paintedTiles,
IEnumerable<Vector2Int> affectedTiles,
Func<Vector2Int, (TilesetResource Tileset, ushort TileId, bool IsVisible, bool UseCollider)> tileProvider )
{
if ( tileProvider == null )
return;
TileLayer layer = ActiveLayer;
if ( layer == null )
return;
var paintedList = paintedTiles?
.Distinct()
.ToList() ?? new List<Vector2Int>();
if ( paintedList.Count == 0 )
return;
var refreshList = (affectedTiles ?? Enumerable.Empty<Vector2Int>())
.Concat( paintedList )
.Distinct()
.ToList();
bool changed = false;
bool erased = false;
// First pass:
// Apply the actual player intent.
// This makes newly painted cells exist in the active layer before sprite refresh happens.
foreach ( var tilePos in paintedList )
{
var paintTile = tileProvider( tilePos );
ushort tilesetId = paintTile.Tileset == null ? (ushort)0 : GetOrAddTilesetId( paintTile.Tileset );
if ( tilesetId != 0 )
{
EnsureCell( layer, tilePos.x, tilePos.y );
}
else
{
// Erasing outside existing bounds does nothing.
if ( !ContainsCell( layer, tilePos.x, tilePos.y ) )
continue;
}
if ( !ContainsCell( layer, tilePos.x, tilePos.y ) )
continue;
int tileIndex = GetIndex( layer, tilePos.x, tilePos.y );
Tile value = new Tile( tilesetId, paintTile.TileId, paintTile.IsVisible, paintTile.UseCollider );
if ( layer.Tiles[tileIndex] == value )
continue;
layer.Tiles[tileIndex] = value;
if ( tilesetId == 0 )
erased = true;
changed = true;
}
// Second pass:
// Refresh autotile sprites after the painted/erased cells are already real.
// This includes the painted cells themselves and only the active layer.
foreach ( var tilePos in refreshList )
{
if ( !ContainsCell( layer, tilePos.x, tilePos.y ) )
continue;
int tileIndex = GetIndex( layer, tilePos.x, tilePos.y );
Tile existingTile = layer.Tiles[tileIndex];
// Empty cells stay empty.
if ( existingTile.TilesetId == 0 )
continue;
var paintTile = tileProvider( tilePos );
ushort tilesetId = paintTile.Tileset == null ? (ushort)0 : GetOrAddTilesetId( paintTile.Tileset );
// Refresh should never erase.
if ( tilesetId == 0 )
continue;
// Do not convert other tilesets.
if ( existingTile.TilesetId != tilesetId )
continue;
Tile value = new Tile( existingTile.TilesetId, paintTile.TileId, paintTile.IsVisible, paintTile.UseCollider );
if ( layer.Tiles[tileIndex] == value )
continue;
layer.Tiles[tileIndex] = value;
changed = true;
}
if ( erased )
{
ShrinkToContents( layer );
}
if ( changed )
{
LayerTilesChanged( layer, painted: true );
}
_wasPaintedLastFrame = true;
}
public TilesetResource GetTileset( ushort tilesetId )
{
if ( tilesetId == 0 )
return null;
EnsureTilesetTables();
int index = tilesetId - 1;
if ( index < 0 || index >= _tilesets.Count )
return null;
Guid storedGuid = index < _tilesetGuids.Count ? _tilesetGuids[index] : Guid.Empty;
string storedPath = index < _tilesetResourcePaths.Count ? _tilesetResourcePaths[index] : string.Empty;
string normalizedStoredPath = NormalizeResourcePath( storedPath );
TilesetResource current = _tilesets[index];
if ( current != null && current.IsValid() )
{
string currentPath = NormalizeResourcePath( current.ResourcePath );
Guid currentGuid = current.GetTilesetGuid();
if ( !string.IsNullOrWhiteSpace( currentPath ) )
{
_tilesetResourcePaths[index] = current.ResourcePath;
// If this slot did not have a path hint yet, the serialized resource reference
// is our best identity. This also repairs slots after duplicate GUIDs are
// regenerated by the editor picker.
if ( string.IsNullOrWhiteSpace( normalizedStoredPath ) )
{
_tilesetGuids[index] = currentGuid;
return current;
}
// If this slot has a path hint and the current reference still points at that
// same asset, trust it even if the GUID was regenerated to fix a duplicate.
if ( currentPath == normalizedStoredPath )
{
_tilesetGuids[index] = currentGuid;
return current;
}
}
if ( storedGuid != Guid.Empty && currentGuid == storedGuid )
{
// If GUIDs are duplicated in the project, the resource reference/path is the
// disambiguator. If this slot has no path hint yet, keep the current reference.
if ( string.IsNullOrWhiteSpace( normalizedStoredPath ) || !IsTilesetGuidDuplicatedInProject( storedGuid ) )
return current;
}
}
TilesetResource resolved = ResolveTileset( storedGuid, storedPath );
if ( resolved != null && resolved.IsValid() )
{
_tilesets[index] = resolved;
_tilesetGuids[index] = resolved.GetTilesetGuid();
_tilesetResourcePaths[index] = resolved.ResourcePath ?? storedPath;
return resolved;
}
return current != null && current.IsValid() ? current : null;
}
public bool IsRenderableTile( Tile tile )
{
return tile.ShouldRender;
}
public bool IsRenderableCell( int x, int z )
{
if ( !TryGetTile( x, z, out var tile ) )
return false;
return IsRenderableTile( tile );
}
public bool IsCollisionTile( Tile tile )
{
return tile.ShouldCollide;
}
public bool IsCollisionCell( int x, int z )
{
if ( !TryGetTile( x, z, out var tile ) )
return false;
return IsCollisionTile( tile );
}
private ushort GetOrAddTilesetId( TilesetResource tileset )
{
EnsureTilesetTables();
if ( tileset == null || !tileset.IsValid() )
return 0;
Guid guid = tileset.GetTilesetGuid();
string resourcePath = tileset.ResourcePath ?? string.Empty;
string normalizedPath = NormalizeResourcePath( resourcePath );
if ( guid == Guid.Empty && string.IsNullOrWhiteSpace( normalizedPath ) )
return 0;
int index = FindTilesetIndex( tileset );
if ( index >= 0 )
{
_tilesets[index] = tileset;
_tilesetGuids[index] = guid;
_tilesetResourcePaths[index] = resourcePath;
return (ushort)(index + 1);
}
for ( int i = 0; i < _tilesets.Count; i++ )
{
if ( _tilesets[i] != null || _tilesetGuids[i] != Guid.Empty || !string.IsNullOrWhiteSpace( _tilesetResourcePaths[i] ) )
continue;
_tilesets[i] = tileset;
_tilesetGuids[i] = guid;
_tilesetResourcePaths[i] = resourcePath;
return (ushort)(i + 1);
}
_tilesets.Add( tileset );
_tilesetGuids.Add( guid );
_tilesetResourcePaths.Add( resourcePath );
return (ushort)_tilesets.Count; // 1-based, 0 = empty
}
private int FindTilesetIndex( TilesetResource tileset )
{
if ( tileset == null )
return -1;
EnsureTilesetTables();
Guid guid = tileset.GetTilesetGuid();
string resourcePath = tileset.ResourcePath ?? string.Empty;
string normalizedPath = NormalizeResourcePath( resourcePath );
// First: exact resource/path match. This is what prevents copied tilesets with
// duplicate GUIDs from being treated as the same tileset.
for ( int i = 0; i < _tilesets.Count; i++ )
{
TilesetResource existing = _tilesets[i];
if ( existing != null && existing.IsValid() && ReferenceEquals( existing, tileset ) )
return i;
string tablePath = NormalizeResourcePath( _tilesetResourcePaths[i] );
if ( string.IsNullOrWhiteSpace( tablePath ) && existing != null && existing.IsValid() )
tablePath = NormalizeResourcePath( existing.ResourcePath );
if ( !string.IsNullOrWhiteSpace( normalizedPath ) && !string.IsNullOrWhiteSpace( tablePath ) && normalizedPath == tablePath )
return i;
}
// Second: GUID match, but only when that GUID is unique in the project.
// A duplicated GUID means two different .tileset files copied the same serialized id,
// so GUID-only matching would merge them and corrupt the tilemap.
if ( guid != Guid.Empty && !IsTilesetGuidDuplicatedInProject( guid ) )
{
for ( int i = 0; i < _tilesetGuids.Count; i++ )
{
if ( _tilesetGuids[i] == guid )
return i;
}
}
return -1;
}
private void EnsureTilesetTables()
{
_tilesets ??= new();
_tilesetGuids ??= new();
_tilesetResourcePaths ??= new();
int count = Math.Max( _tilesets.Count, Math.Max( _tilesetGuids.Count, _tilesetResourcePaths.Count ) );
while ( _tilesets.Count < count )
{
_tilesets.Add( null );
}
while ( _tilesetGuids.Count < count )
{
_tilesetGuids.Add( Guid.Empty );
}
while ( _tilesetResourcePaths.Count < count )
{
_tilesetResourcePaths.Add( string.Empty );
}
for ( int i = 0; i < count; i++ )
{
TilesetResource tileset = _tilesets[i];
if ( tileset == null || !tileset.IsValid() )
continue;
if ( _tilesetGuids[i] == Guid.Empty )
_tilesetGuids[i] = tileset.GetTilesetGuid();
if ( string.IsNullOrWhiteSpace( _tilesetResourcePaths[i] ) && !string.IsNullOrWhiteSpace( tileset.ResourcePath ) )
_tilesetResourcePaths[i] = tileset.ResourcePath;
}
}
private static TilesetResource ResolveTileset( Guid guid, string resourcePathHint )
{
string normalizedPathHint = NormalizeResourcePath( resourcePathHint );
if ( !string.IsNullOrWhiteSpace( normalizedPathHint ) )
{
foreach ( var tileset in ResourceLibrary.GetAll<TilesetResource>() )
{
if ( tileset == null || !tileset.IsValid() )
continue;
if ( NormalizeResourcePath( tileset.ResourcePath ) == normalizedPathHint )
return tileset;
}
}
if ( guid == Guid.Empty )
return null;
// Never resolve a duplicated GUID without a path hint. That was the bug.
if ( IsTilesetGuidDuplicatedInProject( guid ) )
return null;
foreach ( var tileset in ResourceLibrary.GetAll<TilesetResource>() )
{
if ( tileset == null || !tileset.IsValid() )
continue;
if ( tileset.TilesetGuid == guid )
return tileset;
}
return null;
}
private static bool IsSameTilesetResource( TilesetResource a, TilesetResource b )
{
if ( a == null || b == null )
return false;
if ( ReferenceEquals( a, b ) )
return true;
string aPath = NormalizeResourcePath( a.ResourcePath );
string bPath = NormalizeResourcePath( b.ResourcePath );
if ( !string.IsNullOrWhiteSpace( aPath ) && !string.IsNullOrWhiteSpace( bPath ) )
return aPath == bPath;
Guid aGuid = a.GetTilesetGuid();
Guid bGuid = b.GetTilesetGuid();
if ( aGuid == Guid.Empty || bGuid == Guid.Empty || aGuid != bGuid )
return false;
// GUID equality is only trusted if the GUID is unique in the project.
return !IsTilesetGuidDuplicatedInProject( aGuid );
}
private static bool IsTilesetGuidDuplicatedInProject( Guid guid )
{
if ( guid == Guid.Empty )
return false;
HashSet<string> seenPaths = new();
int count = 0;
foreach ( var tileset in ResourceLibrary.GetAll<TilesetResource>() )
{
if ( tileset == null || !tileset.IsValid() )
continue;
if ( tileset.TilesetGuid != guid )
continue;
string normalizedPath = NormalizeResourcePath( tileset.ResourcePath );
if ( !string.IsNullOrWhiteSpace( normalizedPath ) && !seenPaths.Add( normalizedPath ) )
continue;
count++;
if ( count > 1 )
return true;
}
return false;
}
private static string NormalizeResourcePath( string path )
{
return (path ?? string.Empty)
.Replace( '\\', '/' )
.Trim()
.ToLowerInvariant();
}
public bool IsRenderableCell( int layerIndex, int x, int y )
{
TileLayer layer = GetLayer( layerIndex );
if ( layer == null || !layer.IsVisible )
return false;
if ( !TryGetTile( layerIndex, x, y, out var tile ) )
return false;
return IsRenderableTile( tile );
}
public bool IsCollisionCell( int layerIndex, int x, int y )
{
TileLayer layer = GetLayer( layerIndex );
if ( layer == null || !layer.IsVisible || !layer.CollisionsEnabled )
return false;
if ( !TryGetTile( layerIndex, x, y, out var tile ) )
return false;
return IsCollisionTile( tile );
}
public Vector2Int WorldToCell( Vector3 worldPosition, float tileSize )
{
Vector2 mapPosition = WorldToMap( worldPosition );
return new Vector2Int(
MathX.FloorToInt( mapPosition.x / tileSize ),
MathX.FloorToInt( mapPosition.y / tileSize )
);
}
public Vector3 CellToWorld( int x, int y, float tileSize )
{
return CellToWorld( x, y, tileSize, 0.0f );
}
public Vector3 CellToWorld( int x, int y, float tileSize, float normalOffset )
{
return MapToWorld( (x + 0.5f) * tileSize, (y + 0.5f) * tileSize, normalOffset );
}
public Vector2 WorldToMap( Vector3 worldPosition )
{
Vector3 local = worldPosition - GetWorldOrigin();
return Axis switch
{
TileMapAxis.XZ => new Vector2( local.x, local.z ),
TileMapAxis.YZ => new Vector2( local.y, local.z ),
TileMapAxis.XY => new Vector2( local.x, -local.y ),
_ => new Vector2( local.x, local.z )
};
}
public Vector3 MapToWorld( float mapX, float mapY, float normalOffset = 0.0f )
{
Vector3 origin = GetWorldOrigin();
return Axis switch
{
TileMapAxis.XZ => origin + new Vector3( mapX, normalOffset, mapY ),
TileMapAxis.YZ => origin + new Vector3( normalOffset, mapX, mapY ),
TileMapAxis.XY => origin + new Vector3( mapX, -mapY, normalOffset ),
_ => origin + new Vector3( mapX, normalOffset, mapY )
};
}
public Vector3 MapSizeToWorld( float mapWidth, float mapHeight, float normalThickness )
{
mapWidth = System.Math.Max( 0.0f, mapWidth );
mapHeight = System.Math.Max( 0.0f, mapHeight );
normalThickness = System.Math.Max( 0.0f, normalThickness );
return Axis switch
{
TileMapAxis.XZ => new Vector3( mapWidth, normalThickness, mapHeight ),
TileMapAxis.YZ => new Vector3( normalThickness, mapWidth, mapHeight ),
TileMapAxis.XY => new Vector3( mapWidth, mapHeight, normalThickness ),
_ => new Vector3( mapWidth, normalThickness, mapHeight )
};
}
public Vector3 GetPlaneNormal()
{
return Axis switch
{
TileMapAxis.XZ => new Vector3( 0.0f, 1.0f, 0.0f ),
TileMapAxis.YZ => new Vector3( 1.0f, 0.0f, 0.0f ),
TileMapAxis.XY => new Vector3( 0.0f, 0.0f, 1.0f ),
_ => new Vector3( 0.0f, 1.0f, 0.0f )
};
}
public Vector3 GetPlaneAxisU()
{
return Axis switch
{
TileMapAxis.XZ => new Vector3( 1.0f, 0.0f, 0.0f ),
TileMapAxis.YZ => new Vector3( 0.0f, 1.0f, 0.0f ),
TileMapAxis.XY => new Vector3( 1.0f, 0.0f, 0.0f ),
_ => new Vector3( 1.0f, 0.0f, 0.0f )
};
}
public Vector3 GetPlaneAxisV()
{
return Axis switch
{
TileMapAxis.XZ => new Vector3( 0.0f, 0.0f, 1.0f ),
TileMapAxis.YZ => new Vector3( 0.0f, 0.0f, 1.0f ),
TileMapAxis.XY => new Vector3( 0.0f, -1.0f, 0.0f ),
_ => new Vector3( 0.0f, 0.0f, 1.0f )
};
}
public BBox GetCellBounds( Vector2Int cell, float normalThickness = 0.04f )
{
float tileSize = System.Math.Max( TileSize, 0.0001f );
Vector3 center = CellToWorld( cell.x, cell.y, tileSize );
Vector3 size = MapSizeToWorld( tileSize, tileSize, normalThickness );
return BBox.FromPositionAndSize( center, size );
}
public BBox GetMapRectBounds( float centerX, float centerY, float sizeX, float sizeY, float normalThickness = 10.0f )
{
Vector3 center = MapToWorld( centerX, centerY );
Vector3 size = MapSizeToWorld( sizeX, sizeY, normalThickness );
return BBox.FromPositionAndSize( center, size );
}
public bool TryIntersectRayWithPlane( Ray ray, out Vector3 hitPosition )
{
Vector3 normal = GetPlaneNormal();
Vector3 planePoint = GetWorldOrigin();
float denominator = Dot( normal, ray.Forward );
if ( System.MathF.Abs( denominator ) < 0.0001f )
{
hitPosition = default;
return false;
}
float distance = Dot( planePoint - ray.Position, normal ) / denominator;
if ( distance < 0.0f )
{
hitPosition = default;
return false;
}
hitPosition = ray.Position + ray.Forward * distance;
return true;
}
private Vector3 GetWorldOrigin()
{
return GameObject?.WorldPosition ?? Vector3.Zero;
}
private static float Dot( Vector3 a, Vector3 b )
{
return a.x * b.x + a.y * b.y + a.z * b.z;
}
public float GetLayerRenderNormalOffset( int layerIndex )
{
return -Math.Max( 0, layerIndex ) * Math.Max( 0.0f, LayerSpacing );
}
public float GetLayerRenderNormalThickness()
{
return Math.Max( 0.5f, Math.Max( 1, LayerCount ) * Math.Max( 0.0f, LayerSpacing ) + 0.5f );
}
public TileLayer GetLayer( int index )
{
EnsureLayers();
if ( index < 0 || index >= _layers.Count )
return null;
return _layers[index];
}
public int AddLayer( string name = null )
{
EnsureLayers();
var layer = new TileLayer
{
Name = string.IsNullOrWhiteSpace( name ) ? $"Layer {_layers.Count}" : name,
IsVisible = true,
CollisionsEnabled = true,
Width = 1,
Height = 1,
Origin = default,
Tiles = new List<Tile>(),
FilledCells = new List<Vector2Int>(),
FilledCellsDirty = true
};
_layers.Insert( 0, layer );
ActiveLayerIndex = 0;
SyncActiveLayerCache();
MarkTilemapChanged();
return ActiveLayerIndex;
}
public void RemoveLayer( int index )
{
EnsureLayers();
if ( index < 0 || index >= _layers.Count )
return;
if ( _layers.Count <= 1 )
{
ClearLayer( 0 );
return;
}
var activeLayer = ActiveLayer;
_layers.RemoveAt( index );
int activeIndex = _layers.IndexOf( activeLayer );
ActiveLayerIndex = activeIndex >= 0 ? activeIndex : Math.Clamp( ActiveLayerIndex, 0, _layers.Count - 1 );
SyncActiveLayerCache();
MarkTilemapChanged();
}
public void ClearLayer( int index )
{
TileLayer layer = GetLayer( index );
if ( layer == null )
return;
layer.Tiles.Clear();
layer.Width = 1;
layer.Height = 1;
layer.Origin = default;
layer.FilledCells.Clear();
layer.FilledCellsDirty = true;
SyncActiveLayerCache();
MarkTilemapChanged( painted: true );
}
public void MoveLayer( int fromIndex, int toIndex )
{
EnsureLayers();
if ( fromIndex < 0 || fromIndex >= _layers.Count )
return;
toIndex = Math.Clamp( toIndex, 0, _layers.Count - 1 );
if ( fromIndex == toIndex )
return;
var activeLayer = ActiveLayer;
var layer = _layers[fromIndex];
_layers.RemoveAt( fromIndex );
_layers.Insert( toIndex, layer );
int activeIndex = _layers.IndexOf( activeLayer );
ActiveLayerIndex = activeIndex >= 0 ? activeIndex : Math.Clamp( ActiveLayerIndex, 0, _layers.Count - 1 );
SyncActiveLayerCache();
MarkTilemapChanged();
}
public void SetActiveLayer( int index )
{
EnsureLayers();
int clamped = Math.Clamp( index, 0, _layers.Count - 1 );
if ( ActiveLayerIndex == clamped )
return;
ActiveLayerIndex = clamped;
SyncActiveLayerCache();
}
public void SetLayerVisible( int index, bool visible )
{
TileLayer layer = GetLayer( index );
if ( layer == null || layer.IsVisible == visible )
return;
layer.IsVisible = visible;
MarkTilemapChanged();
}
public void SetLayerCollisionsEnabled( int index, bool enabled )
{
TileLayer layer = GetLayer( index );
if ( layer == null || layer.CollisionsEnabled == enabled )
return;
layer.CollisionsEnabled = enabled;
MarkTilemapChanged();
}
private void EnsureLayers()
{
_layers ??= new();
_tiles ??= new();
if ( _layers.Count == 0 )
{
var layer = new TileLayer
{
Name = "Layer 0",
IsVisible = true,
CollisionsEnabled = true,
Tiles = _tiles != null && _tiles.Count > 0 ? new List<Tile>( _tiles ) : new List<Tile>(),
Width = Math.Max( 1, Width ),
Height = Math.Max( 1, Height ),
Origin = Origin,
FilledCells = new List<Vector2Int>(),
FilledCellsDirty = true
};
if ( layer.Tiles.Count == 0 || layer.Tiles.Count != layer.Width * layer.Height )
{
layer.Tiles.Clear();
layer.Width = 1;
layer.Height = 1;
layer.Origin = default;
}
_layers.Add( layer );
}
for ( int i = 0; i < _layers.Count; i++ )
{
var layer = _layers[i];
if ( layer == null )
{
layer = new TileLayer();
_layers[i] = layer;
}
if ( string.IsNullOrWhiteSpace( layer.Name ) )
layer.Name = $"Layer {i}";
layer.Tiles ??= new();
layer.FilledCells ??= new();
if ( layer.Width < 1 ) layer.Width = 1;
if ( layer.Height < 1 ) layer.Height = 1;
if ( layer.Tiles.Count != 0 && layer.Tiles.Count != layer.Width * layer.Height )
{
layer.Tiles.Clear();
layer.Width = 1;
layer.Height = 1;
layer.Origin = default;
layer.FilledCellsDirty = true;
}
}
ActiveLayerIndex = Math.Clamp( ActiveLayerIndex, 0, _layers.Count - 1 );
SyncActiveLayerCache();
}
private void SyncActiveLayerCache()
{
if ( _layers == null || _layers.Count == 0 )
return;
var layer = _layers[Math.Clamp( ActiveLayerIndex, 0, _layers.Count - 1 )];
_tiles = layer.Tiles;
Width = layer.Width;
Height = layer.Height;
Origin = layer.Origin;
}
private void MarkTilemapChanged( bool painted = false )
{
Revision++;
_tilesDirty = true;
if ( painted )
_wasPaintedLastFrame = true;
}
private void LayerTilesChanged( TileLayer layer, bool painted )
{
if ( layer != null )
layer.FilledCellsDirty = true;
SyncActiveLayerCache();
MarkTilemapChanged( painted );
}
private void MarkAllLayerFilledCellsDirty()
{
EnsureLayers();
foreach ( var layer in _layers )
{
if ( layer != null )
layer.FilledCellsDirty = true;
}
}
public IEnumerable<Vector2Int> GetFilledCells()
{
return GetFilledCells( ActiveLayerIndex );
}
public IEnumerable<Vector2Int> GetFilledCells( int layerIndex )
{
TileLayer layer = GetLayer( layerIndex );
if ( layer == null )
return Enumerable.Empty<Vector2Int>();
EnsureFilledCellsCache( layer );
return layer.FilledCells;
}
private void EnsureFilledCellsCache( TileLayer layer )
{
if ( layer == null || !layer.FilledCellsDirty )
return;
layer.FilledCells.Clear();
// Guard against uninitialized or mismatched state.
if ( layer.Tiles.Count != layer.Width * layer.Height || layer.Tiles.Count == 0 )
{
layer.FilledCellsDirty = false;
return;
}
for ( int y = 0; y < layer.Height; y++ )
{
for ( int x = 0; x < layer.Width; x++ )
{
if ( !layer.Tiles[y * layer.Width + x].IsEmpty )
layer.FilledCells.Add( new Vector2Int( x + layer.Origin.x, y + layer.Origin.y ) );
}
}
layer.FilledCellsDirty = false;
}
private bool ContainsCell( TileLayer layer, int x, int y )
{
if ( layer == null )
return false;
int localX = x - layer.Origin.x;
int localY = y - layer.Origin.y;
return localX >= 0 && localX < layer.Width && localY >= 0 && localY < layer.Height && layer.Tiles.Count == layer.Width * layer.Height;
}
private int GetIndex( TileLayer layer, int x, int y )
{
int localX = x - layer.Origin.x;
int localY = y - layer.Origin.y;
return localY * layer.Width + localX;
}
private void EnsureCell( TileLayer layer, int x, int y )
{
if ( layer == null )
return;
if ( layer.Tiles.Count == 0 )
{
Resize( layer, x, y, 1, 1 );
return;
}
int minX = Math.Min( layer.Origin.x, x );
int minY = Math.Min( layer.Origin.y, y );
int maxX = Math.Max( layer.Origin.x + layer.Width - 1, x );
int maxY = Math.Max( layer.Origin.y + layer.Height - 1, y );
if ( minX == layer.Origin.x && minY == layer.Origin.y && maxX == layer.Origin.x + layer.Width - 1 && maxY == layer.Origin.y + layer.Height - 1 )
return;
Resize( layer, minX, minY, maxX - minX + 1, maxY - minY + 1 );
}
private void Resize( TileLayer layer, int newOriginX, int newOriginY, int newWidth, int newHeight )
{
if ( layer == null )
return;
newWidth = Math.Max( 1, newWidth );
newHeight = Math.Max( 1, newHeight );
var newTiles = new List<Tile>( newWidth * newHeight );
for ( int i = 0; i < newWidth * newHeight; i++ )
newTiles.Add( new Tile() );
if ( layer.Tiles.Count == layer.Width * layer.Height && layer.Tiles.Count > 0 )
{
for ( int y = 0; y < layer.Height; y++ )
{
for ( int x = 0; x < layer.Width; x++ )
{
Tile tile = layer.Tiles[y * layer.Width + x];
if ( tile.IsEmpty )
continue;
int worldX = layer.Origin.x + x;
int worldY = layer.Origin.y + y;
int localX = worldX - newOriginX;
int localY = worldY - newOriginY;
if ( localX < 0 || localX >= newWidth || localY < 0 || localY >= newHeight )
continue;
newTiles[localY * newWidth + localX] = tile;
}
}
}
layer.Origin = new Vector2Int( newOriginX, newOriginY );
layer.Width = newWidth;
layer.Height = newHeight;
layer.Tiles.Clear();
layer.Tiles.AddRange( newTiles );
layer.FilledCellsDirty = true;
SyncActiveLayerCache();
}
private void ShrinkToContents( TileLayer layer )
{
if ( layer == null )
return;
int minX = int.MaxValue;
int minY = int.MaxValue;
int maxX = int.MinValue;
int maxY = int.MinValue;
bool found = false;
for ( int y = 0; y < layer.Height; y++ )
{
for ( int x = 0; x < layer.Width; x++ )
{
if ( layer.Tiles.Count != layer.Width * layer.Height || layer.Tiles[y * layer.Width + x].IsEmpty )
continue;
found = true;
int worldX = layer.Origin.x + x;
int worldY = layer.Origin.y + y;
minX = Math.Min( minX, worldX );
minY = Math.Min( minY, worldY );
maxX = Math.Max( maxX, worldX );
maxY = Math.Max( maxY, worldY );
}
}
if ( !found )
{
layer.Origin = default;
layer.Width = 1;
layer.Height = 1;
layer.Tiles.Clear();
layer.FilledCells.Clear();
layer.FilledCellsDirty = true;
SyncActiveLayerCache();
return;
}
Resize( layer, minX, minY, maxX - minX + 1, maxY - minY + 1 );
}
}