Terrain/ParkTerrain.cs
using HC3.Persistence;
using System;

namespace HC3.Terrain;

#nullable enable

/// <summary>
/// Describes a grid of <see cref="TerrainTile"/>s. Needs a <see cref="TerrainMesh"/> to be visible / have collision.
/// </summary>
public sealed class ParkTerrain : Component, ITerrainData, ISaveDataProperty<TileArraySlice>,
	Component.INetworkSnapshot
{
	private TileArraySlice _data = new( 64 );

	private TileArraySlice Data
	{
		get => _data;
		set
		{
			if ( _data.Equals( value ) ) return;

			_data = value;

			Bounds = new RectInt( -value.Size / 2, value.Size );

			ITerrainEvent.Post( x => x.Changed( this, Bounds ) );
		}
	}

	/// <summary>
	/// How many tiles big is this terrain.
	/// </summary>
	[Property]
	public Vector2Int Size
	{
		get => _data.Size;
		set
		{
			value = Vector2Int.Max( 0, value );

			if ( _data.Size == value ) return;

			Data = new TileArraySlice( value );
		}
	}

	/// <summary>
	/// Tile range that can be accessed.
	/// </summary>
	public RectInt Bounds { get; private set; }

	/// <summary>
	/// Get or set a tile at the given grid index. Must be within <see cref="Bounds"/>.
	/// </summary>
	public TerrainTile this[Vector2Int index]
	{
		get
		{
			var local = index - Bounds.Position;

			if ( local.x < 0 || local.y < 0 ) return TerrainTile.LevelGround( 0 );
			if ( local.x >= Size.x || local.y >= Size.y ) return TerrainTile.LevelGround( 0 );

			return _data[index - Bounds.Position];
		}
		set
		{
			var local = index - Bounds.Position;

			if ( local.x < 0 || local.y < 0 ) return;
			if ( local.x >= Size.x || local.y >= Size.y ) return;

			if ( _data[local].Equals( value ) ) return;

			_data[local] = value;

			ITerrainEvent.Post( x => x.Changed( this, new RectInt( index, 1 ) ) );
		}
	}

	/// <summary>
	/// Horizontal size of one tile.
	/// </summary>
	public int TileWidth => GridManager.GridSize;

	/// <summary>
	/// Smallest increment between tile heights.
	/// </summary>
	public int TileHeight => GridManager.HeightStep;

	private const int DefaultTilesetDetail = 2;

	private int _tilesetDetail = DefaultTilesetDetail;

	private SlopeTileset? _tileset;

	private readonly Dictionary<(int, int), SlopeTileset> _maxFlatGradientTilesets = new();

	/// <summary>
	/// Describes which slopes are valid in this terrain.
	/// </summary>
	public SlopeTileset Tileset => _tileset ??= SlopeTileset.FromMaxEdgeGradient( TileSlope.All, 1 << _tilesetDetail );

	/// <summary>
	/// Controls the complexity of <see cref="Tileset"/>. A value of <c>1</c> matches RCT, each level higher will
	/// double the range of gradients supported.
	/// </summary>
	[Property, Range( 1f, 3f ), Step( 1f )]
	public int TilesetDetail
	{
		get => _tilesetDetail + 1;
		set
		{
			value -= 1;

			if ( _tilesetDetail == value ) return;

			_tilesetDetail = Math.Clamp( value, 0, 2 );

			_tileset = null;
			_maxFlatGradientTilesets.Clear();
		}
	}

	/// <summary>
	/// Gets a reduced version of <see cref="Tileset"/> that only has flat slopes with given maximum gradients
	/// in the X or Y directions. Useful for things like paths.
	/// </summary>
	public SlopeTileset GetTileset( int maxGradientX, int maxGradientY )
	{
		if ( _maxFlatGradientTilesets.TryGetValue( (maxGradientX, maxGradientY), out var tileset ) ) return tileset;

		tileset = SlopeTileset.FromMaxGradients( Tileset, maxGradientX, maxGradientY );

		_maxFlatGradientTilesets[(maxGradientX, maxGradientY)] = tileset;

		return tileset;
	}

	/// <summary>
	/// Set a range of tiles. More efficient than going one-by-one with <see cref="this"/>.
	/// </summary>
	public void SetTiles( RectInt range, TileArraySlice tiles )
	{
		var clampedRange = range.Clamp( Bounds );

		tiles.Slice( new RectInt( clampedRange.Position - range.Position, clampedRange.Size ) )
			.CopyTo( _data.Slice( clampedRange.Offset( -Bounds.Position ) ) );

		ITerrainEvent.Post( x => x.Changed( this, range ) );
	}

	/// <summary>
	/// Set all the tiles in the given range to be flat, with the given height.
	/// </summary>
	public void SetTiles( RectInt range, int height )
	{
		_data.Slice( range.Clamp( Bounds ).Offset( -Bounds.Position ) )
			.Fill( TerrainTile.LevelGround( height ) );
	}

	/// <summary>
	/// Copy a range of tiles to a tile slice. More efficient than going one-by-one with <see cref="this"/>.
	/// </summary>
	public void CopyTo( RectInt srcRange, TileArraySlice dst ) =>
		_data.CopyTo( srcRange.Offset( -Bounds.Position ), dst );

	/// <summary>
	/// Map from world space to a tile index and height offset.
	/// </summary>
	public Vector3 WorldToGrid( Vector3 worldPos )
	{
		var localPos = Transform.World.PointToLocal( worldPos );
		return localPos / new Vector3( TileWidth, TileWidth, TileHeight );
	}

	/// <summary>
	/// Map from grid space to world position.
	/// </summary>
	public Vector3 GridToWorld( Vector3 gridPos ) =>
		Transform.World.PointToWorld( gridPos * new Vector3( TileWidth, TileWidth, TileHeight ) );

	/// <summary>
	/// Map from grid space to grid index, clamping to <see cref="Bounds"/>.
	/// </summary>
	public Vector2Int GetTileIndex( Vector2 gridPos ) => new(
		Math.Clamp( (int)MathF.Floor( gridPos.x ), Bounds.Left, Bounds.Right - 1 ),
		Math.Clamp( (int)MathF.Floor( gridPos.y ), Bounds.Top, Bounds.Bottom - 1 ) );

	public Vector3Int GetTileIndex( Vector3 gridPos ) => new(
		Math.Clamp( (int)MathF.Floor( gridPos.x ), Bounds.Left, Bounds.Right - 1 ),
		Math.Clamp( (int)MathF.Floor( gridPos.y ), Bounds.Top, Bounds.Bottom - 1 ),
		(int)MathF.Floor( gridPos.z ) );


	/// <summary>
	/// Get the closest corner to the given grid-space position.
	/// </summary>
	public TileCorner GetTileCorner( Vector2 gridPos )
	{
		gridPos -= GetTileIndex( gridPos );

		return gridPos.x < 0.5f
			? gridPos.y < 0.5f ? TileCorner.XMinYMin : TileCorner.XMinYMax
			: gridPos.y < 0.5f ? TileCorner.XMaxYMin : TileCorner.XMaxYMax;
	}

	/// <summary>
	/// Get the minimum and maximum heights of tiles in the given range.
	/// </summary>
	public (int Min, int Max) GetHeightRange( RectInt tileRange )
	{
		tileRange = tileRange.Clamp( Bounds ).Offset( -Bounds.Position );

		return Data.Slice( tileRange ).GetHeightRange();
	}

	/// <summary>
	/// Gets the terrain height of the given position.
	/// </summary>
	public float GetHeight( Vector2 gridPos )
	{
		var tileIndex = GetTileIndex( gridPos );

		gridPos -= tileIndex;

		return this[tileIndex].GetHeight( gridPos );
	}

	void INetworkSnapshot.WriteSnapshot( ref ByteStream writer )
	{
		writer.Write( Size );
		Data.WriteUncompressed( ref writer );
	}

	void INetworkSnapshot.ReadSnapshot( ref ByteStream reader )
	{
		var size = reader.Read<Vector2Int>();
		Data = TileArraySlice.FromUncompressed( size, default, ref reader );
	}

	string ISaveDataProperty.PropertyName => "Terrain";
	int ISaveDataProperty.PropertyOrder => -1_000;

	TileArraySlice ISaveDataProperty<TileArraySlice>.WriteValue( Scene scene ) => Data;

	void ISaveDataProperty<TileArraySlice>.ReadValue( Scene scene, TileArraySlice model )
	{
		Data = model;

		ITerrainEvent.Post( x => x.Loaded( this ) );
	}

	public (int Height, int Paint) GetTotalDifference( RectInt range, TileArraySlice tiles )
	{
		var clampedRange = range.Clamp( Bounds );

		tiles = tiles.Slice( new RectInt( clampedRange.Position - range.Position, clampedRange.Size ) );

		var slice = _data.Slice( clampedRange.Offset( -Bounds.Position ) );
		var heightSum = 0;
		var paintSum = 0;

		foreach ( var (index, changed) in tiles )
		{
			var original = slice[index];

			heightSum += original.Slope.GetTotalDistance( changed.Slope, changed.MinHeight - original.MinHeight );
			paintSum += original.Paint.GetTotalDistance( changed.Paint );
		}

		return (heightSum, paintSum);
	}
}

public interface ITerrainEvent : ISceneEvent<ITerrainEvent>
{

	/// <summary>
	/// Called when the terrain is changed.
	/// </summary>
	void Changed( ParkTerrain terrain, RectInt tileRange ) { }

	/// <summary>
	/// Called when the terrain is generated. This is only called on the host.
	/// </summary>
	void Generated( ParkTerrain terrain, int seed ) { }

	/// <summary>
	/// Called when the terrain is loaded. This is only called on the host.
	/// </summary>
	void Loaded( ParkTerrain terrain ) { }

	/// <summary>
	/// Called when the entrance is moved. This is only called on the host.
	/// </summary>
	void EntranceMoved( Vector3Int gridPos ) { }
}