Terrain/TileArraySlice.cs
using System;
using System.Buffers;
using System.Collections;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HC3.Terrain;
#nullable enable
public readonly record struct IndexedTile( Vector2Int Index, TerrainTile Tile );
/// <summary>
/// Wraps an array of <see cref="TerrainTile"/>s with a bunch of helper methods for accessing them.
/// You can slice this into smaller ranges, without allocating, using <see cref="Slice(RectInt)"/>.
/// </summary>
[JsonConverter( typeof( TileArraySliceConverter ) )]
public readonly struct TileArraySlice : ITerrainData,
IEnumerable<IndexedTile>,
IEquatable<TileArraySlice>
{
private readonly TerrainTile[] _array;
private readonly int _offset;
private readonly int _stride;
public Vector2Int Size { get; }
public RectInt Bounds => new( 0, Size );
public TerrainTile this[Vector2Int tileIndex]
{
get => _array[GetIndex( tileIndex )];
set => _array[GetIndex( tileIndex )] = value;
}
private int GetIndex( Vector2Int tileIndex ) => _offset + tileIndex.x + tileIndex.y * _stride;
public TileArraySlice( Vector2Int size )
: this( size == 0 ? [] : new TerrainTile[size.x * size.y], size )
{
}
internal TileArraySlice( TerrainTile[] array, Vector2Int size )
: this( array, size, 0, size.x )
{
}
internal TileArraySlice( TerrainTile[] array, Vector2Int size, int offset, int stride )
{
ArgumentOutOfRangeException.ThrowIfLessThan( size.x, 0, nameof( size ) );
ArgumentOutOfRangeException.ThrowIfLessThan( size.y, 0, nameof( size ) );
ArgumentOutOfRangeException.ThrowIfLessThan( offset, 0, nameof( offset ) );
ArgumentOutOfRangeException.ThrowIfLessThan( stride, size.x, nameof( stride ) );
ArgumentOutOfRangeException.ThrowIfGreaterThan( _offset + size.x + (size.y - 1) * stride, array.Length, nameof( size ) );
_array = array;
_offset = offset;
_stride = stride;
Size = size;
}
public TileArraySlice SliceX( int offset, int size ) => Slice( new RectInt( offset, 0, size, Size.y ) );
public TileArraySlice SliceY( int offset, int size ) => Slice( new RectInt( 0, offset, Size.x, size ) );
public TileArraySlice Slice( Vector2Int size ) => Slice( new RectInt( 0, size ) );
public TileArraySlice Slice( RectInt range )
{
if ( range.Position == 0 && range.Size == Size ) return this;
var min = range.Position;
if ( min.x < 0 || min.y < 0 )
{
throw new ArgumentOutOfRangeException( nameof( range ) );
}
var max = range.Position + range.Size;
if ( max.x > Size.x || max.y > Size.y )
{
throw new ArgumentOutOfRangeException( nameof( range ) );
}
return new TileArraySlice( _array, range.Size, GetIndex( range.Position ), _stride );
}
public void CopyTo( RectInt srcRange, TileArraySlice dst ) =>
Slice( srcRange ).CopyTo( dst );
public void CopyTo( TileArraySlice dst )
{
if ( dst.Size.x < Size.x || dst.Size.y < Size.y )
{
throw new ArgumentOutOfRangeException( nameof( dst ), "Destination array too small." );
}
for ( var y = 0; y < Size.y; ++y )
{
var firstTile = new Vector2Int( 0, y );
var srcSpan = _array.AsSpan( GetIndex( firstTile ), Size.x );
var dstSpan = dst._array.AsSpan( dst.GetIndex( firstTile ), Size.x );
srcSpan.CopyTo( dstSpan );
}
}
public void Clear()
{
for ( var y = 0; y < Size.y; ++y )
{
var firstTile = new Vector2Int( 0, y );
Array.Clear( _array, GetIndex( firstTile ), Size.x );
}
}
public void Fill( TerrainTile tile )
{
for ( var y = 0; y < Size.y; ++y )
{
var firstTile = new Vector2Int( 0, y );
Array.Fill( _array, tile, GetIndex( firstTile ), Size.x );
}
}
public (int Min, int Max) GetHeightRange()
{
var min = int.MaxValue;
var max = int.MinValue;
foreach ( var (_, tile) in this )
{
min = Math.Min( min, tile.MinHeight );
max = Math.Max( max, tile.MaxHeight );
}
return min > max ? default : (min, max);
}
public IEnumerator<IndexedTile> GetEnumerator()
{
// TODO: non-allocating enumerator
var thisCopy = this;
return Enumerable.Range( 0, Size.y ).SelectMany( y =>
Enumerable.Range( 0, thisCopy.Size.x ).Select( x =>
{
var index = new Vector2Int( x, y );
return new IndexedTile( index, thisCopy[index] );
} ) ).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void WriteUncompressed( ref ByteStream stream )
{
if ( _offset == 0 && _stride == Size.x )
{
stream.WriteArray( _array );
}
else
{
var arrayCopy = ArrayPool<TerrainTile>.Shared.Rent( Size.x * Size.y );
try
{
stream.WriteArray<TerrainTile>( arrayCopy.AsSpan( 0, Size.x * Size.y ) );
}
finally
{
ArrayPool<TerrainTile>.Shared.Return( arrayCopy );
}
}
}
private void CopyTo( Span<TerrainTile> span )
{
for ( var y = 0; y < Size.y; ++y )
{
var firstTile = new Vector2Int( 0, y );
var srcSpan = _array.AsSpan( GetIndex( firstTile ), Size.x );
var dstSpan = span.Slice( y * Size.x, Size.x );
srcSpan.CopyTo( dstSpan );
}
}
public TerrainTile[] ToArray()
{
if ( _array.Length == Size.x * Size.y )
{
return _array;
}
var array = new TerrainTile[Size.x * Size.y];
CopyTo( array );
return array;
}
[Flags]
public enum CompressedTerrainFlags
{
Legacy = 1
}
private readonly record struct LegacyTerrainTile( ushort BaseHeight, TileSlope Slope );
public static TileArraySlice FromUncompressed( Vector2Int size, CompressedTerrainFlags flags, ref ByteStream stream )
{
TerrainTile[] array;
if ( (flags & CompressedTerrainFlags.Legacy) != 0 )
{
array = stream.ReadArray<LegacyTerrainTile>( size.x * size.y )
.ToArray()
.Select( x => new TerrainTile( x.BaseHeight, x.Slope, default ) )
.ToArray();
}
else
{
array = stream.ReadArray<TerrainTile>( size.x * size.y )
.ToArray();
}
return new TileArraySlice( array, size );
}
public string ToBase64()
{
var stream = ByteStream.Create( Size.x * Size.y * TerrainTile.SizeBytes );
try
{
WriteUncompressed( ref stream );
var compressed = stream.Compress();
return Convert.ToBase64String( compressed.ToArray() );
}
finally
{
stream.Dispose();
}
}
public static TileArraySlice FromBase64( Vector2Int size, int version, string base64 )
{
var compressedBytes = Convert.FromBase64String( base64 );
using var compressed = ByteStream.CreateReader( compressedBytes );
var stream = compressed.Decompress();
try
{
return FromUncompressed( size, version < 2 ? CompressedTerrainFlags.Legacy : 0, ref stream );
}
finally
{
stream.Dispose();
}
}
public bool Equals( TileArraySlice other )
{
return _array.Equals( other._array ) && _offset == other._offset && _stride == other._stride && Size.Equals( other.Size );
}
public override bool Equals( object? obj )
{
return obj is TileArraySlice other && Equals( other );
}
public override int GetHashCode()
{
return HashCode.Combine( _array, _offset, _stride, Size );
}
}
file sealed class TileArraySliceConverter : JsonConverter<TileArraySlice>
{
public const int CurrentVersion = 2;
private sealed record Model( Vector2Int Size, int Version, string Tiles );
public override TileArraySlice Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
{
return JsonSerializer.Deserialize<Model>( ref reader, options ) is { } model
? TileArraySlice.FromBase64( model.Size, model.Version, model.Tiles )
: new TileArraySlice( 0 );
}
public override void Write( Utf8JsonWriter writer, TileArraySlice value, JsonSerializerOptions options )
{
var model = new Model( value.Size, CurrentVersion, value.ToBase64() );
JsonSerializer.Serialize( writer, model, options );
}
}