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 ) { }
}