Represents the rectangular playfield (arena) for a grid-based game. It stores width and height, provides bounds checking, converts between (x,y) and flat indices, computes the centre cell, and computes the maximum starting snake length given a facing direction.
namespace Coilgarden;
/// <summary>
/// The playfield's bounds. A plain object with no engine dependency, so the rules that
/// depend on it can be tested without a scene.
/// <para>
/// Deliberately knows nothing about what is standing on it. Occupancy belongs to the
/// snake, which is the only thing that occupies cells; keeping the two apart means there
/// is exactly one authority on "is this cell taken" rather than two that can disagree.
/// </para>
/// </summary>
public sealed class Arena
{
public Arena( int width, int height )
{
if ( width < 3 ) throw new System.ArgumentOutOfRangeException( nameof( width ), "An arena narrower than 3 cells has no room to turn around in." );
if ( height < 3 ) throw new System.ArgumentOutOfRangeException( nameof( height ), "An arena shorter than 3 cells has no room to turn around in." );
Width = width;
Height = height;
}
public int Width { get; }
public int Height { get; }
public int CellCount => Width * Height;
public bool Contains( GridPos cell ) =>
cell.X >= 0 && cell.X < Width && cell.Y >= 0 && cell.Y < Height;
/// <summary>The cell nearest the middle. Used as the snake's starting head.</summary>
public GridPos Centre => new( Width / 2, Height / 2 );
/// <summary>
/// The longest starting body that fits when the head is at the centre facing
/// <paramref name="facing"/>, since the body is laid out behind the head.
/// <para>
/// The limit is the distance from the centre to the wall the body runs towards, not the
/// arena's full width - a 8-wide arena has its centre at x=4 and only 5 cells behind it.
/// Getting that wrong is what made a "clamped" start length still be silently truncated
/// by <see cref="Snake.Reset"/>.
/// </para>
/// <para>
/// Static, and duplicated nowhere: both <see cref="GameRules.Clamped"/> and
/// <see cref="Snake.Reset"/> ask this, so the rules cannot claim a length the snake will
/// not actually be built at.
/// </para>
/// </summary>
public static int MaxStartLength( int width, int height, Direction facing )
{
var behind = facing.Opposite().Delta();
if ( behind.X != 0 )
{
var centre = width / 2;
return behind.X < 0 ? centre + 1 : width - centre;
}
var centreY = height / 2;
return behind.Y < 0 ? centreY + 1 : height - centreY;
}
/// <summary>The longest starting body this arena can hold, facing the given way.</summary>
public int MaxStartLength( Direction facing ) => MaxStartLength( Width, Height, facing );
/// <summary>
/// A flat index for this cell, for array-backed lookups. Callers are expected to have
/// already established the cell is in bounds.
/// </summary>
public int IndexOf( GridPos cell ) => cell.Y * Width + cell.X;
public GridPos FromIndex( int index ) => new( index % Width, index / Width );
}