feat: initial project structure with Snake.Core, CLI and Avalonia skeleton

This commit is contained in:
Heller
2026-06-17 19:02:07 +00:00
commit 42fcaf8631
25 changed files with 661 additions and 0 deletions

24
Snake.Core/Board.cs Normal file
View File

@@ -0,0 +1,24 @@
namespace Snake.Core;
public sealed class Board
{
public Board(int width, int height)
{
if (width < 2)
throw new ArgumentOutOfRangeException(nameof(width), "Width must be at least 2.");
if (height < 2)
throw new ArgumentOutOfRangeException(nameof(height), "Height must be at least 2.");
Width = width;
Height = height;
}
public int Width { get; }
public int Height { get; }
public bool IsWithinBounds(Position position) =>
position.X >= 0 && position.X < Width &&
position.Y >= 0 && position.Y < Height;
}

9
Snake.Core/Direction.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace Snake.Core;
public enum Direction
{
Up,
Down,
Left,
Right
}

38
Snake.Core/Food.cs Normal file
View File

@@ -0,0 +1,38 @@
namespace Snake.Core;
public sealed class Food
{
private readonly Random _random;
public Food(Board board, IEnumerable<Position> occupiedPositions, Random? random = null)
{
_random = random ?? Random.Shared;
Board = board;
Respawn(occupiedPositions);
}
public Board Board { get; }
public Position Position { get; private set; }
public void Respawn(IEnumerable<Position> occupiedPositions)
{
var occupied = occupiedPositions.ToHashSet();
var freeCells = new List<Position>();
for (var y = 0; y < Board.Height; y++)
{
for (var x = 0; x < Board.Width; x++)
{
var position = new Position(x, y);
if (!occupied.Contains(position))
freeCells.Add(position);
}
}
if (freeCells.Count == 0)
throw new InvalidOperationException("No free cells available for food.");
Position = freeCells[_random.Next(freeCells.Count)];
}
}

View File

@@ -0,0 +1,8 @@
namespace Snake.Core;
public enum GameTickResult
{
Moved,
AteFood,
GameOver
}

3
Snake.Core/Position.cs Normal file
View File

@@ -0,0 +1,3 @@
namespace Snake.Core;
public readonly record struct Position(int X, int Y);

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

80
Snake.Core/Snake.cs Normal file
View File

@@ -0,0 +1,80 @@
namespace Snake.Core;
public sealed class Snake
{
private readonly LinkedList<Position> _segments = new();
public Snake(Position start, int length, Direction direction)
{
if (length < 1)
throw new ArgumentOutOfRangeException(nameof(length), "Length must be at least 1.");
Direction = direction;
var offset = DirectionToOffset(direction);
for (var i = 0; i < length; i++)
{
_segments.AddLast(new Position(
start.X - offset.X * i,
start.Y - offset.Y * i));
}
}
public Direction Direction { get; private set; }
public Position Head => _segments.First!.Value;
public IReadOnlyCollection<Position> Segments => _segments;
public void SetDirection(Direction direction)
{
if (direction == Direction)
return;
if (AreOpposite(Direction, direction))
return;
Direction = direction;
}
public Position PeekNextHead()
{
var offset = DirectionToOffset(Direction);
return new Position(Head.X + offset.X, Head.Y + offset.Y);
}
public Position Move(bool grow = false)
{
var newHead = PeekNextHead();
_segments.AddFirst(newHead);
if (!grow)
_segments.RemoveLast();
return newHead;
}
public bool Occupies(Position position) =>
_segments.Contains(position);
private static Position DirectionToOffset(Direction direction) =>
direction switch
{
Direction.Up => new Position(0, -1),
Direction.Down => new Position(0, 1),
Direction.Left => new Position(-1, 0),
Direction.Right => new Position(1, 0),
_ => throw new ArgumentOutOfRangeException(nameof(direction))
};
private static bool AreOpposite(Direction current, Direction next) =>
(current, next) switch
{
(Direction.Up, Direction.Down) => true,
(Direction.Down, Direction.Up) => true,
(Direction.Left, Direction.Right) => true,
(Direction.Right, Direction.Left) => true,
_ => false
};
}

60
Snake.Core/SnakeGame.cs Normal file
View File

@@ -0,0 +1,60 @@
namespace Snake.Core;
public sealed class SnakeGame
{
public SnakeGame(int width, int height, Random? random = null)
{
_random = random ?? Random.Shared;
Board = new Board(width, height);
var start = new Position(width / 2, height / 2);
Snake = new Snake(start, length: 3, Direction.Right);
Food = new Food(Board, Snake.Segments, _random);
}
private readonly Random _random;
public Board Board { get; }
public Snake Snake { get; }
public Food Food { get; }
public int Score { get; private set; }
public bool IsGameOver { get; private set; }
public void SetDirection(Direction direction) => Snake.SetDirection(direction);
public GameTickResult Tick()
{
if (IsGameOver)
return GameTickResult.GameOver;
var newHead = Snake.PeekNextHead();
if (!Board.IsWithinBounds(newHead) || Snake.Occupies(newHead))
{
IsGameOver = true;
return GameTickResult.GameOver;
}
var ateFood = newHead == Food.Position;
Snake.Move(grow: ateFood);
if (ateFood)
{
Score++;
if (Snake.Segments.Count >= Board.Width * Board.Height)
{
IsGameOver = true;
return GameTickResult.AteFood;
}
Food.Respawn(Snake.Segments);
return GameTickResult.AteFood;
}
return GameTickResult.Moved;
}
}