Terrain/TilePaint.cs
using System;

namespace HC3.Terrain;

/// <summary>
/// Describes how a single tile has been painted.
/// Each corner can have a different material.
/// </summary>
public readonly struct TilePaint : IEquatable<TilePaint>
{
	internal const int SizeBytes = sizeof( ushort );

	private readonly ushort _encoded;

	private const int BitsPerCorner = (SizeBytes << 3) >> 2;
	private const ushort CornerBitMask = (1 << BitsPerCorner) - 1;

	public const int MaxMaterialIndex = CornerBitMask;

	private TilePaint( ushort encoded )
	{
		_encoded = encoded;
	}

	/// <summary>
	/// Gets the material index of a given tile corner. This index is used to look up
	/// a material in a <see cref="TerrainPalette"/>.
	/// </summary>
	public int GetMaterialIndex( TileCorner corner )
	{
		var shift = (int)corner * BitsPerCorner;

		return (_encoded & (CornerBitMask << shift)) >> shift;
	}

	/// <summary>
	/// Returns this tile paint with a particular corner's material index replaced.
	/// </summary>
	public TilePaint WithMaterialIndex( TileCorner corner, int index )
	{
		ArgumentOutOfRangeException.ThrowIfLessThan( index, 0 );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( index, MaxMaterialIndex );

		var shift = (int)corner * BitsPerCorner;
		var withoutCorner = _encoded & ~(CornerBitMask << shift);

		return new TilePaint( (ushort)(withoutCorner | (index << shift)) );
	}

	public bool Equals( TilePaint other ) => _encoded == other._encoded;
	public override bool Equals( object obj ) => obj is TilePaint other && Equals( other );
	public override int GetHashCode() => _encoded.GetHashCode();

	public override string ToString() =>
		$"[{GetMaterialIndex( TileCorner.XMinYMin )}, " +
		$"{GetMaterialIndex( TileCorner.XMaxYMin )}, " +
		$"{GetMaterialIndex( TileCorner.XMinYMax )}, " +
		$"{GetMaterialIndex( TileCorner.XMaxYMax )}]";

	public static bool operator ==( TilePaint a, TilePaint b ) => a.Equals( b );
	public static bool operator !=( TilePaint a, TilePaint b ) => !a.Equals( b );

	/// <summary>
	/// Count how many corners have a different material index compared to <paramref name="other"/>.
	/// </summary>
	public int GetTotalDistance( TilePaint other )
	{
		var sum = 0;

		sum += GetMaterialIndex( TileCorner.XMinYMin ) != other.GetMaterialIndex( TileCorner.XMinYMin ) ? 1 : 0;
		sum += GetMaterialIndex( TileCorner.XMaxYMin ) != other.GetMaterialIndex( TileCorner.XMaxYMin ) ? 1 : 0;
		sum += GetMaterialIndex( TileCorner.XMinYMax ) != other.GetMaterialIndex( TileCorner.XMinYMax ) ? 1 : 0;
		sum += GetMaterialIndex( TileCorner.XMaxYMax ) != other.GetMaterialIndex( TileCorner.XMaxYMax ) ? 1 : 0;

		return sum;
	}

	public static TilePaint FromMaterialIndex( int index )
	{
		ArgumentOutOfRangeException.ThrowIfLessThan( index, 0, nameof( index ) );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( index, MaxMaterialIndex, nameof( index ) );

		return new TilePaint( (ushort)(index | (index << BitsPerCorner) | (index << (BitsPerCorner * 2)) | (index << (BitsPerCorner * 3))) );
	}

	public static TilePaint FromMaterialIndices( ReadOnlySpan<int> indices )
	{
		ArgumentOutOfRangeException.ThrowIfNotEqual( indices.Length, 4, nameof( indices ) );

		ArgumentOutOfRangeException.ThrowIfLessThan( indices[0], 0, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( indices[1], 0, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( indices[2], 0, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( indices[3], 0, nameof( indices ) );

		ArgumentOutOfRangeException.ThrowIfGreaterThan( indices[0], MaxMaterialIndex, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( indices[1], MaxMaterialIndex, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( indices[2], MaxMaterialIndex, nameof( indices ) );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( indices[3], MaxMaterialIndex, nameof( indices ) );

		return new TilePaint( (ushort)(indices[0] | (indices[1] << BitsPerCorner) | (indices[2] << (BitsPerCorner * 2)) | (indices[3] << (BitsPerCorner * 3))) );
	}
}