feat: fully functional CLI and Avalonia UI for Snake game
This commit is contained in:
@@ -3,8 +3,11 @@
|
|||||||
xmlns:views="using:Snake.Avalonia.Views"
|
xmlns:views="using:Snake.Avalonia.Views"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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"
|
x:Class="Snake.Avalonia.MainWindow"
|
||||||
Title="Snake">
|
Title="Snake"
|
||||||
|
Width="480"
|
||||||
|
Height="420"
|
||||||
|
CanResize="False">
|
||||||
<views:GameView />
|
<views:GameView />
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Snake.Avalonia.Views.GameView">
|
x:Class="Snake.Avalonia.Views.GameView"
|
||||||
<TextBlock Text="Snake game UI — connect Snake.Core here"
|
Focusable="True">
|
||||||
HorizontalAlignment="Center"
|
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
|
||||||
VerticalAlignment="Center" />
|
<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>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,15 +1,176 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
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;
|
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
|
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()
|
public GameView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ internal static class ConsoleRenderer
|
|||||||
public static void Render(SnakeGame game)
|
public static void Render(SnakeGame game)
|
||||||
{
|
{
|
||||||
Console.Clear();
|
Console.Clear();
|
||||||
Console.WriteLine($"Score: {game.Score}");
|
Console.WriteLine($"Score: {game.Score} Level: {game.Level}");
|
||||||
|
|
||||||
if (game.IsGameOver)
|
if (game.IsGameOver)
|
||||||
Console.WriteLine("Game Over! Press R to restart or Q to quit.");
|
Console.WriteLine("Game Over! Press R to restart or Q to quit.");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Snake.Core;
|
|||||||
|
|
||||||
const int boardWidth = 20;
|
const int boardWidth = 20;
|
||||||
const int boardHeight = 15;
|
const int boardHeight = 15;
|
||||||
const int tickMs = 120;
|
const int baseTickMs = 120;
|
||||||
|
|
||||||
SnakeGame game = new(boardWidth, boardHeight);
|
SnakeGame game = new(boardWidth, boardHeight);
|
||||||
ConsoleRenderer.Render(game);
|
ConsoleRenderer.Render(game);
|
||||||
@@ -40,5 +40,5 @@ while (true)
|
|||||||
game.Tick();
|
game.Tick();
|
||||||
|
|
||||||
ConsoleRenderer.Render(game);
|
ConsoleRenderer.Render(game);
|
||||||
Thread.Sleep(tickMs);
|
Thread.Sleep(SnakeGame.GetTickIntervalMs(game.Score, baseTickMs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ public sealed class SnakeGame
|
|||||||
|
|
||||||
public bool IsGameOver { get; private set; }
|
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 void SetDirection(Direction direction) => Snake.SetDirection(direction);
|
||||||
|
|
||||||
public GameTickResult Tick()
|
public GameTickResult Tick()
|
||||||
|
|||||||
Reference in New Issue
Block a user