feat: fully functional CLI and Avalonia UI for Snake game

This commit is contained in:
Heller
2026-06-17 19:03:22 +00:00
parent 42fcaf8631
commit 0d9710965c
6 changed files with 207 additions and 13 deletions

View File

@@ -3,8 +3,11 @@
xmlns:views="using:Snake.Avalonia.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="420"
x:Class="Snake.Avalonia.MainWindow"
Title="Snake">
Title="Snake"
Width="480"
Height="420"
CanResize="False">
<views:GameView />
</Window>

View File

@@ -1,7 +1,30 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Snake.Avalonia.Views.GameView">
<TextBlock Text="Snake game UI — connect Snake.Core here"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
x:Class="Snake.Avalonia.Views.GameView"
Focusable="True">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock x:Name="ScoreText" FontSize="16" />
<TextBlock x:Name="LevelText" FontSize="16" Margin="24,0,0,0" />
<TextBlock x:Name="StatusText" FontSize="16" Margin="24,0,0,0" />
</StackPanel>
<Border Grid.Row="1"
BorderBrush="#666"
BorderThickness="1"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Canvas x:Name="GameCanvas"
Background="#1a1a1a"
Focusable="True" />
</Border>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Center"
Margin="0,12,0,0">
<Button x:Name="StartButton" Content="Start" Width="100" />
<Button x:Name="RestartButton" Content="Restart" Width="100" Margin="12,0,0,0" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -1,15 +1,176 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using Snake.Core;
namespace Snake.Avalonia.Views;
/// <summary>
/// Placeholder for the Avalonia game view.
/// Wire up <see cref="Snake.Core.SnakeGame"/> here for rendering and input.
/// </summary>
public partial class GameView : UserControl
{
private const int BoardWidth = 20;
private const int BoardHeight = 15;
private const double CellSize = 20;
private const int BaseTickMs = 120;
private SnakeGame _game = new(BoardWidth, BoardHeight);
private readonly DispatcherTimer _timer;
private bool _isRunning;
public GameView()
{
InitializeComponent();
GameCanvas.Width = BoardWidth * CellSize;
GameCanvas.Height = BoardHeight * CellSize;
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(BaseTickMs) };
_timer.Tick += OnTimerTick;
StartButton.Click += OnStartClick;
RestartButton.Click += OnRestartClick;
KeyDown += OnKeyDown;
GameCanvas.PointerPressed += (_, _) => Focus();
UpdateHud();
RenderBoard();
}
private void OnStartClick(object? sender, RoutedEventArgs e)
{
if (_isRunning && !_game.IsGameOver)
return;
if (_game.IsGameOver)
ResetGame();
StartGame();
}
private void OnRestartClick(object? sender, RoutedEventArgs e)
{
ResetGame();
StartGame();
}
private void ResetGame()
{
_game = new SnakeGame(BoardWidth, BoardHeight);
_isRunning = false;
_timer.Stop();
UpdateHud();
RenderBoard();
}
private void StartGame()
{
_isRunning = true;
_timer.Interval = TimeSpan.FromMilliseconds(SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs));
_timer.Start();
UpdateHud();
Focus();
}
private void OnTimerTick(object? sender, EventArgs e)
{
if (!_isRunning || _game.IsGameOver)
return;
_game.Tick();
if (_game.IsGameOver)
{
_isRunning = false;
_timer.Stop();
}
else
{
var interval = SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs);
if (_timer.Interval != TimeSpan.FromMilliseconds(interval))
_timer.Interval = TimeSpan.FromMilliseconds(interval);
}
UpdateHud();
RenderBoard();
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (!_isRunning && !_game.IsGameOver && e.Key is Key.Up or Key.Down or Key.Left or Key.Right)
{
StartGame();
}
switch (e.Key)
{
case Key.Up:
_game.SetDirection(Direction.Up);
break;
case Key.Down:
_game.SetDirection(Direction.Down);
break;
case Key.Left:
_game.SetDirection(Direction.Left);
break;
case Key.Right:
_game.SetDirection(Direction.Right);
break;
}
}
private void UpdateHud()
{
ScoreText.Text = $"Score: {_game.Score}";
LevelText.Text = $"Level: {_game.Level}";
StatusText.Text = _game.IsGameOver
? "Game Over!"
: _isRunning
? "Playing"
: "Press Start or an arrow key";
}
private void RenderBoard()
{
GameCanvas.Children.Clear();
var snakeCells = _game.Snake.Segments.ToHashSet();
var head = _game.Snake.Head;
for (var y = 0; y < BoardHeight; y++)
{
for (var x = 0; x < BoardWidth; x++)
{
var position = new Position(x, y);
if (!snakeCells.Contains(position))
continue;
var rect = new Rectangle
{
Width = CellSize - 1,
Height = CellSize - 1,
Fill = position == head ? Brushes.LimeGreen : Brushes.Green
};
Canvas.SetLeft(rect, x * CellSize);
Canvas.SetTop(rect, y * CellSize);
GameCanvas.Children.Add(rect);
}
}
var food = _game.Food.Position;
var foodRect = new Rectangle
{
Width = CellSize - 1,
Height = CellSize - 1,
Fill = Brushes.Red
};
Canvas.SetLeft(foodRect, food.X * CellSize);
Canvas.SetTop(foodRect, food.Y * CellSize);
GameCanvas.Children.Add(foodRect);
}
}

View File

@@ -7,7 +7,7 @@ internal static class ConsoleRenderer
public static void Render(SnakeGame game)
{
Console.Clear();
Console.WriteLine($"Score: {game.Score}");
Console.WriteLine($"Score: {game.Score} Level: {game.Level}");
if (game.IsGameOver)
Console.WriteLine("Game Over! Press R to restart or Q to quit.");

View File

@@ -3,7 +3,7 @@ using Snake.Core;
const int boardWidth = 20;
const int boardHeight = 15;
const int tickMs = 120;
const int baseTickMs = 120;
SnakeGame game = new(boardWidth, boardHeight);
ConsoleRenderer.Render(game);
@@ -40,5 +40,5 @@ while (true)
game.Tick();
ConsoleRenderer.Render(game);
Thread.Sleep(tickMs);
Thread.Sleep(SnakeGame.GetTickIntervalMs(game.Score, baseTickMs));
}

View File

@@ -24,6 +24,13 @@ public sealed class SnakeGame
public bool IsGameOver { get; private set; }
public const int PointsPerLevel = 5;
public int Level => 1 + Score / PointsPerLevel;
public static int GetTickIntervalMs(int score, int baseIntervalMs = 120, int minIntervalMs = 50) =>
Math.Max(minIntervalMs, baseIntervalMs - (1 + score / PointsPerLevel - 1) * 10);
public void SetDirection(Direction direction) => Snake.SetDirection(direction);
public GameTickResult Tick()