Tilemap/TileMapCollider.cs

A collider component for a TileMap that builds physics box shapes from tile collision data. It scans visible, collision-enabled layers, creates a binary collision map, computes optimal bounding boxes, converts them to world positions/sizes, adds box shapes to the PhysicsBody, and marks the body static.

File Access
using Sandbox;
using Sandbox.Helper;
using System.Collections.Generic;
using System.Linq;

namespace Saandy.Tilemapper;

public sealed class TileMapCollider : Collider, ITilemapSceneEvent
{
	[Property, Order(-1)] public TileMap Tilemap { get; set; }

	protected override IEnumerable<PhysicsShape> CreatePhysicsShapes( PhysicsBody targetBody, Transform local )
	{
		if(!Tilemap.IsValid()) { return targetBody.Shapes; }

		Log.Info( targetBody.GameObject.Name );
		targetBody.ClearShapes();

		if ( Tilemap == null || !Tilemap.IsValid() )
			return targetBody.Shapes;

		for ( int layerIndex = 0; layerIndex < Tilemap.LayerCount; layerIndex++ )
		{
			var layer = Tilemap.GetLayer( layerIndex );

			if ( layer == null || !layer.IsVisible || !layer.CollisionsEnabled )
				continue;

			if ( layer.Tiles == null || layer.Tiles.Count == 0 || layer.Tiles.Count != layer.Width * layer.Height )
				continue;

			// Important:
			// Do not use TilesetId directly here.
			// A tile can be "painted / mask-present" while still being a visual empty
			// placeholder for a specific brush, for example Bitmask2x2Edge tile 15.
			List<int> collisionMap = layer.Tiles
				.Select( tile => Tilemap.IsCollisionTile( tile ) ? 1 : 0 )
				.ToList();

			List<BBox> colBBoxes = Histogram.GetOptimalQuadding(
				collisionMap,
				layer.Width,
				layer.Height
			).ToList();

			for ( int i = 0; i < colBBoxes.Count; i++ )
			{
				BBox box = colBBoxes[i];

				// Histogram boxes are in local layer array coordinates. Add the layer origin
				// to get map coordinates, then let TileMap convert those coordinates onto
				// the selected world plane. Collision shapes are not layer-offset because
				// these layers are for 2D gameplay.
				float mapX = (box.Center.x + layer.Origin.x) * Tilemap.TileSize;
				float mapY = (box.Center.y + layer.Origin.y) * Tilemap.TileSize;

				Vector3 pos = Tilemap.MapToWorld( mapX, mapY );
				Vector3 size = Tilemap.MapSizeToWorld(
					box.Size.x * Tilemap.TileSize,
					box.Size.y * Tilemap.TileSize,
					16
				);

				DebugOverlay.Box( pos, size, Color.Red, 1 );

				PhysicsShape shape = targetBody.AddBoxShape( pos, Rotation.Identity, size * 0.5f, rebuildMass: false );
				shape.Surface = Surface;
			}
		}

		targetBody.BodyType = PhysicsBodyType.Static;
		targetBody.Surface = Surface;

		return targetBody.Shapes;
	}

	void ITilemapSceneEvent.OnTilemapStable()
	{
		if(Game.IsPlaying) { return; }
		RebuildImmediately();
	}
}