Editor/Brush/TileBrush.cs

Abstract editor TileBrush for a tilemap editor. Defines built-in brushes, rasterizes brush strokes, determines which tiles are painted and which neighbouring tiles are affected, and delegates per-cell sprite/flag computation to concrete brushes.

File Access
using Editor.TerrainEditor;
using Saandy.Tilemapper;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Saandy.Editor.Tilemapper;

public abstract class TileBrush
{
	public static readonly TileBrush Manual = new TileBrushManual();
	public static readonly TileBrush Bitmask2x2Edge = new TileBrush2x2Edge();
	public static readonly TileBrush Bitmask3x3Complex = new TileBrush3x3Complex();

	public static TileBrush GetBrush( BrushType brush ) => brush switch
	{
		BrushType.Manual => Manual,
		BrushType.Edge2x2 => Bitmask2x2Edge,
		BrushType.Complex3x3 => Bitmask3x3Complex,
		_ => null
	};

	public abstract string Name { get; protected set; }

	public void Draw( TileMap tilemap, TilesetResource tileset, Vector2Int from, Vector2Int to, bool erase = false )
	{
		if ( tilemap == null )
			return;

		if ( tileset == null )
			return;

		var paintedTiles = RasterizeLine( from, to )
			.Distinct()
			.ToList();

		if ( paintedTiles.Count == 0 )
			return;

		var paintedSet = paintedTiles.ToHashSet();

		var affectedTiles = paintedTiles
			.SelectMany( GetAffectedTiles )
			.Where( t => !paintedSet.Contains( t ) )
			.Distinct()
			.ToList();

		tilemap.SetTiles(
			paintedTiles,
			affectedTiles,
			pos =>
			{
				bool isPaintedTile = paintedSet.Contains( pos );

				// Painted cells always use the active tileset/brush.
				if ( isPaintedTile )
				{
					if ( erase )
						return (null, (ushort)0, false, false);

					return GetTileToSet( tilemap, tileset, default, pos.x, pos.y, isRefresh: false );
				}

				// Affected cells already exist in the tilemap. Refresh them using their own
				// tileset and that tileset's brush, not the brush currently being painted with.
				if ( !tilemap.TryGetTile( pos.x, pos.y, out var existingTile ) || existingTile.IsEmpty )
					return (null, (ushort)0, false, false);

				var existingTileset = tilemap.GetTileset( existingTile.TilesetId );

				if ( existingTileset == null || !existingTileset.IsValid() )
					return (null, (ushort)0, false, false);

				var existingBrush = GetBrush( existingTileset.BrushType );

				if ( existingBrush == null )
					return (existingTileset, existingTile.TileId, existingTile.IsVisible, existingTile.UseCollider);

				return existingBrush.GetTileToSet( tilemap, existingTileset, existingTile, pos.x, pos.y, isRefresh: true );
			}
		);
	}

	/// <summary>
	/// Builds the tile data that should be written for this brush at a specific cell.
	/// Autotile brushes recompute their sprite. Manual brush overrides this during refresh
	/// so neighbouring autotile changes do not overwrite manually selected tile ids.
	/// </summary>
	public virtual (TilesetResource Tileset, ushort TileId, bool IsVisible, bool UseCollider) GetTileToSet(
		TileMap tilemap,
		TilesetResource tileset,
		TileMap.Tile existingTile,
		int x,
		int y,
		bool isRefresh )
	{
		ushort spriteIndex = GetSpriteIndexToSet( tilemap, tileset, x, y );
		var flags = GetDefaultTileFlags( spriteIndex );

		return (tileset, spriteIndex, flags.IsVisible, flags.UseCollider);
	}

	/// <summary>
	/// Per-cell flags written into TileMap.Tile when this brush paints a sprite.
	/// Override this in brushes that need logical/mask tiles that should not render or collide.
	/// </summary>
	public abstract (bool IsVisible, bool UseCollider) GetDefaultTileFlags( ushort spriteIndex );

	/// <summary>
	/// Legacy helper for callers that only care about map adjacency.
	/// Manual brush needs the TilesetResource overload so it can read the selected manual tile.
	/// </summary>
	public virtual ushort GetSpriteIndexToSet( TileMap tilemap, int x, int y )
	{
		return GetSpriteIndexToSet( tilemap, null, x, y );
	}

	/// <summary>
	/// What tile sprite index should exist at the given map cell.
	/// </summary>
	public abstract ushort GetSpriteIndexToSet( TileMap tilemap, TilesetResource tileset, int x, int y );

	/// <summary>
	/// Get a list of cells that are affected by changing this cell.
	/// </summary>
	protected abstract IEnumerable<Vector2Int> GetAffectedTiles( Vector2Int cell );

	protected List<Vector2Int> RasterizeLine( Vector2Int from, Vector2Int to )
	{
		var cells = new List<Vector2Int>();

		int x0 = from.x;
		int y0 = from.y;
		int x1 = to.x;
		int y1 = to.y;

		int dx = Math.Abs( x1 - x0 );
		int sx = x0 < x1 ? 1 : -1;
		int dy = -Math.Abs( y1 - y0 );
		int sy = y0 < y1 ? 1 : -1;
		int error = dx + dy;

		while ( true )
		{
			cells.Add( new Vector2Int( x0, y0 ) );

			if ( x0 == x1 && y0 == y1 )
				break;

			int doubledError = error * 2;
			if ( doubledError >= dy )
			{
				error += dy;
				x0 += sx;
			}

			if ( doubledError <= dx )
			{
				error += dx;
				y0 += sy;
			}
		}

		return cells;
	}
}