Code/Tilemap/TileMap.cs

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.

File AccessNetworking
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 );
	}
}