feat: smooth snake animation + beautiful Avalonia UI overhaul

- Two-timer architecture: game timer + 60fps render timer for smooth interpolation
- Snake body: StreamGeometry path with teal gradient, rounded joins
- Directional head with white eyes and dark pupils
- Food: pulsating glow, highlight, green leaf animation
- Modern dark theme (#0D1117), glassmorphism HUD
- Speed indicator bar, score +N popup
- High score persistence to JSON
- All keyboard shortcuts: Arrows, WASD, Space/P pause, Enter start, R restart, Esc quit
- Window resizable, 640x540 default

New files: AnimationHelper.cs, HighScoreManager.cs, SnakeRenderer.cs
This commit is contained in:
Heller
2026-06-19 10:02:07 +00:00
parent 2e89c6dca3
commit 7d55a08380
9 changed files with 1027 additions and 150 deletions

View File

@@ -1,10 +1,38 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Snake.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
<!-- Custom styles for game buttons -->
<Style Selector="Button.GameButton">
<Setter Property="Background" Value="#0D9488" />
<Setter Property="Foreground" Value="#E6EDF3" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Button.GameButton:pointerover">
<Setter Property="Background" Value="#14B8A6" />
</Style>
<Style Selector="Button.GameButton:pressed">
<Setter Property="Background" Value="#0F766E" />
</Style>
<Style Selector="Button.GameButtonSecondary">
<Setter Property="Background" Value="#1C2128" />
<Setter Property="BorderBrush" Value="#30363D" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.GameButtonSecondary:pointerover">
<Setter Property="Background" Value="#30363D" />
</Style>
</Application.Styles>
</Application>
</Application>

View File

@@ -3,11 +3,14 @@
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="480" d:DesignHeight="420"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="540"
x:Class="Snake.Avalonia.MainWindow"
Title="Snake"
Width="480"
Height="420"
CanResize="False">
Title="🐍 Snake"
MinWidth="540"
MinHeight="460"
Width="640"
Height="540"
Background="#0D1117"
ExtendClientAreaToDecorationsHint="False">
<views:GameView />
</Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace Snake.Avalonia;
@@ -7,5 +8,15 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
// Global Escape handler to close the window
KeyDown += (_, e) =>
{
if (e.Key == Key.Escape)
{
Close();
e.Handled = true;
}
};
}
}
}

View File

@@ -0,0 +1,105 @@
using Snake.Core;
namespace Snake.Avalonia.Views;
/// <summary>
/// Interpolation helpers for smooth snake animation.
/// </summary>
public static class AnimationHelper
{
/// <summary>
/// Ease-out cubic for smooth deceleration (head overshoot feel).
/// </summary>
public static double EaseOutCubic(double t)
{
t = Math.Clamp(t, 0.0, 1.0);
return 1.0 - Math.Pow(1.0 - t, 3);
}
/// <summary>
/// Ease-in-out quad for general smooth interpolation.
/// </summary>
public static double EaseInOutQuad(double t)
{
t = Math.Clamp(t, 0.0, 1.0);
return t < 0.5 ? 2.0 * t * t : 1.0 - Math.Pow(-2.0 * t + 2.0, 2) / 2.0;
}
/// <summary>
/// Interpolates between two positions with given t value.
/// Returns the visual position for rendering.
/// </summary>
public static (double X, double Y) InterpolatePosition(
Position from,
Position to,
double t,
double cellSize,
bool useEasing = false)
{
var eased = useEasing ? EaseOutCubic(t) : t;
var x = from.X + (to.X - from.X) * eased;
var y = from.Y + (to.Y - from.Y) * eased;
return (x * cellSize + cellSize * 0.5, y * cellSize + cellSize * 0.5);
}
/// <summary>
/// Maps a current segment index to its previous position for interpolation.
/// PreviousSegments is the full snapshot before Move().
/// Segments count may differ from PreviousSegments if the snake grew.
/// </summary>
public static Position GetPreviousPosition(
int segmentIndex,
IReadOnlyList<Position> currentSegments,
IReadOnlyList<Position> previousSegments,
Direction direction)
{
if (previousSegments.Count == 0)
return currentSegments[segmentIndex];
// Head (index 0) always interpolates from the old head
if (segmentIndex == 0)
return previousSegments[0];
// For body segments, the mapping depends on whether snake grew
var grew = currentSegments.Count > previousSegments.Count;
if (grew)
{
// When growing: all old segments kept their positions,
// new head was added at front. Body segments shifted by 1.
// current[i] (i>=1) = old[i-1]
var prevIdx = segmentIndex - 1;
if (prevIdx < previousSegments.Count)
return previousSegments[prevIdx];
}
else
{
// When not growing: tail removed, new head added.
// current[0] = new head, current[1] = old head, etc.
// current[i] (i>=1) was at old[i-1]
var prevIdx = segmentIndex - 1;
if (prevIdx < previousSegments.Count)
return previousSegments[prevIdx];
}
// Fallback: return current position (no interpolation)
return currentSegments[segmentIndex];
}
/// <summary>
/// Computes a pulsating radius for food glow effect.
/// </summary>
public static double PulsateRadius(double baseRadius, double elapsedSeconds, double amplitude = 0.15, double frequency = 2.5)
{
return baseRadius * (1.0 + amplitude * Math.Sin(elapsedSeconds * Math.PI * 2.0 * frequency));
}
/// <summary>
/// Computes opacity for score popup fade-out animation.
/// </summary>
public static double FadeOutOpacity(double elapsedSeconds, double durationSeconds = 1.0)
{
var t = Math.Clamp(elapsedSeconds / durationSeconds, 0.0, 1.0);
return 1.0 - t;
}
}

View File

@@ -1,30 +1,175 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:Snake.Avalonia.Views"
x:Class="Snake.Avalonia.Views.GameView"
Background="#0D1117"
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" />
<Grid RowDefinitions="Auto,*">
<!-- HUD Panel — glassmorphism top bar -->
<Border Grid.Row="0"
Background="#141C24"
BorderBrush="#1E3A3A"
BorderThickness="0,0,0,1"
Padding="16,10">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<!-- Score -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6">
<TextBlock Text="🍎" FontSize="16" VerticalAlignment="Center" />
<TextBlock x:Name="ScoreText"
FontSize="16"
FontWeight="Bold"
Foreground="#E6EDF3" />
</StackPanel>
<!-- Spacer -->
<TextBlock Grid.Column="1" />
<!-- Level -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" Margin="0,0,24,0">
<TextBlock Text="⚡" FontSize="14" VerticalAlignment="Center" />
<TextBlock x:Name="LevelText"
FontSize="14"
Foreground="#8B949E"
VerticalAlignment="Center" />
</StackPanel>
<!-- Speed indicator bar -->
<Border Grid.Column="3"
Width="60" Height="16"
Background="#1C2128"
CornerRadius="8"
Margin="0,0,24,0"
VerticalAlignment="Center">
<Border x:Name="SpeedBar"
Background="#0D9488"
CornerRadius="8"
HorizontalAlignment="Left"
Width="15" />
</Border>
<!-- High Score -->
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
<TextBlock Text="🏆" FontSize="14" VerticalAlignment="Center" />
<TextBlock x:Name="HighScoreText"
FontSize="14"
Foreground="#FBBF24"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</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>
<!-- Game canvas area -->
<Grid Grid.Row="1">
<!-- Snake renderer control -->
<views:SnakeRenderer x:Name="SnakeCanvas"
BoardWidth="20"
BoardHeight="15"
CellSize="28"
Width="560"
Height="420"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<!-- Start screen overlay -->
<Border x:Name="StartOverlay"
Background="#0D1117"
IsVisible="True"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="16">
<TextBlock Text="🐍"
FontSize="48"
HorizontalAlignment="Center" />
<TextBlock Text="SNAKE"
FontSize="36"
FontWeight="Bold"
Foreground="#14B8A6"
HorizontalAlignment="Center" />
<TextBlock Text="Press ENTER or Arrow Keys to Start"
FontSize="16"
Foreground="#8B949E"
HorizontalAlignment="Center"
Margin="0,12,0,0" />
<TextBlock Text="WASD / Arrows = Move · Space = Pause · R = Restart · Esc = Quit"
FontSize="12"
Foreground="#484F58"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<!-- Pause overlay -->
<Border x:Name="PauseOverlay"
Background="#800D1117"
IsVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<TextBlock Text="⏸"
FontSize="48"
HorizontalAlignment="Center" />
<TextBlock Text="PAUSED"
FontSize="28"
FontWeight="Bold"
Foreground="#8B949E"
HorizontalAlignment="Center" />
<TextBlock Text="Press Space or P to Resume"
FontSize="14"
Foreground="#484F58"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<!-- Game Over overlay -->
<Border x:Name="GameOverOverlay"
Background="#B30D1117"
IsVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<TextBlock Text="💀"
FontSize="48"
HorizontalAlignment="Center" />
<TextBlock Text="GAME OVER"
FontSize="32"
FontWeight="Bold"
Foreground="#EF4444"
HorizontalAlignment="Center" />
<TextBlock x:Name="GameOverScoreText"
FontSize="18"
Foreground="#E6EDF3"
HorizontalAlignment="Center" />
<TextBlock x:Name="GameOverHighScoreText"
FontSize="14"
Foreground="#FBBF24"
HorizontalAlignment="Center" />
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="16"
Margin="0,16,0,0">
<Button x:Name="GameOverRestartBtn"
Content="🔄 Restart"
Classes="GameButton"
Width="140" />
<Button x:Name="GameOverQuitBtn"
Content="✕ Quit"
Classes="GameButton GameButtonSecondary"
Width="140" />
</StackPanel>
</StackPanel>
</Border>
<!-- Score popup (will be rendered by SnakeRenderer) -->
</Grid>
</Grid>
</UserControl>

View File

@@ -1,6 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
@@ -13,44 +12,242 @@ 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 readonly DispatcherTimer _gameTimer;
private readonly DispatcherTimer _renderTimer;
private DateTime _lastTickTime;
private DateTime _lastRenderTime;
private bool _isRunning;
private bool _isPaused;
private int _highScore;
// Score popup state
private int _scorePopupValue;
private double _scorePopupElapsed;
private Position _scorePopupPosition;
public GameView()
{
InitializeComponent();
GameCanvas.Width = BoardWidth * CellSize;
GameCanvas.Height = BoardHeight * CellSize;
// Load high score
_highScore = HighScoreManager.Load();
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(BaseTickMs) };
_timer.Tick += OnTimerTick;
// Game timer — ticks the game logic
_gameTimer = new DispatcherTimer();
_gameTimer.Tick += OnGameTick;
StartButton.Click += OnStartClick;
RestartButton.Click += OnRestartClick;
KeyDown += OnKeyDown;
GameCanvas.PointerPressed += (_, _) => Focus();
// Render timer — 60fps smooth animation
_renderTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
_renderTimer.Tick += OnRenderTick;
// Button handlers
GameOverRestartBtn.Click += (_, _) => RestartGame();
GameOverQuitBtn.Click += (_, _) => CloseWindow();
// Keyboard
KeyDown += OnGlobalKeyDown;
// Focus on click
PointerPressed += (_, _) => Focus();
// Initial HUD
UpdateHud();
RenderBoard();
// Start render loop immediately (for idle animation)
_lastRenderTime = DateTime.UtcNow;
_renderTimer.Start();
}
private void OnStartClick(object? sender, RoutedEventArgs e)
private void OnGlobalKeyDown(object? sender, KeyEventArgs e)
{
if (_isRunning && !_game.IsGameOver)
var key = e.Key;
// Quit
if (key == Key.Escape)
{
CloseWindow();
return;
}
// Restart
if (key == Key.R)
{
RestartGame();
return;
}
// Pause/Resume
if (key == Key.Space || key == Key.P)
{
TogglePause();
return;
}
// Start game
if (key == Key.Enter && (!_isRunning || _game.IsGameOver))
{
if (_game.IsGameOver)
ResetGame();
StartGame();
return;
}
// Direction input — also starts game if not running
var dir = KeyToDirection(key);
if (dir.HasValue)
{
if (!_isRunning && !_game.IsGameOver)
{
StartGame();
}
if (_isRunning && !_game.IsGameOver && !_isPaused)
{
_game.SetDirection(dir.Value);
}
}
e.Handled = true;
}
private static Direction? KeyToDirection(Key key) => key switch
{
Key.Up or Key.W => Direction.Up,
Key.Down or Key.S => Direction.Down,
Key.Left or Key.A => Direction.Left,
Key.Right or Key.D => Direction.Right,
_ => null
};
private void OnGameTick(object? sender, EventArgs e)
{
if (!_isRunning || _game.IsGameOver || _isPaused)
return;
if (_game.IsGameOver)
ResetGame();
_lastTickTime = DateTime.UtcNow;
StartGame();
var result = _game.Tick();
if (_game.IsGameOver)
{
_isRunning = false;
_gameTimer.Stop();
// Save high score
if (_game.Score > _highScore)
{
_highScore = _game.Score;
HighScoreManager.Save(_highScore);
}
ShowGameOver();
}
else
{
// Update game speed based on level
var interval = SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs);
var newInterval = TimeSpan.FromMilliseconds(interval);
if (_gameTimer.Interval != newInterval)
_gameTimer.Interval = newInterval;
// Food eaten popup
if (result == GameTickResult.AteFood)
{
_scorePopupValue = _game.Score;
_scorePopupElapsed = 0;
_scorePopupPosition = _game.Food.Position;
}
}
UpdateHud();
}
private void OnRestartClick(object? sender, RoutedEventArgs e)
private void OnRenderTick(object? sender, EventArgs e)
{
var now = DateTime.UtcNow;
_lastRenderTime = now;
if (_isRunning && !_isPaused && !_game.IsGameOver)
{
// Calculate interpolation T
var elapsedSinceTick = (now - _lastTickTime).TotalMilliseconds;
var tickInterval = SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs);
var t = Math.Clamp(elapsedSinceTick / tickInterval, 0.0, 1.0);
SnakeCanvas.InterpolationT = t;
}
else
{
// Keep t frozen when paused/game over
SnakeCanvas.InterpolationT = Math.Clamp(SnakeCanvas.InterpolationT, 0.0, 1.0);
}
// Update score popup timer
if (_scorePopupElapsed < 1.5)
{
_scorePopupElapsed += 0.016; // ~60fps
}
// Update snake state for renderer
SnakeCanvas.Segments = _game.Snake.Segments.ToList();
SnakeCanvas.PreviousSegments = _game.Snake.PreviousSegments;
SnakeCanvas.SnakeDirection = _game.Snake.Direction;
SnakeCanvas.FoodPosition = _game.Food.Position;
SnakeCanvas.TotalElapsedSeconds += 0.016;
// Score popup
SnakeCanvas.ScorePopup = _scorePopupValue;
SnakeCanvas.ScorePopupElapsed = _scorePopupElapsed;
SnakeCanvas.ScorePopupPosition = _scorePopupPosition;
// Trigger redraw
SnakeCanvas.InvalidateVisual();
}
private void StartGame()
{
_isRunning = true;
_isPaused = false;
var interval = SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs);
_gameTimer.Interval = TimeSpan.FromMilliseconds(interval);
_lastTickTime = DateTime.UtcNow;
_gameTimer.Start();
StartOverlay.IsVisible = false;
PauseOverlay.IsVisible = false;
GameOverOverlay.IsVisible = false;
UpdateHud();
Focus();
}
private void TogglePause()
{
if (!_isRunning || _game.IsGameOver)
return;
_isPaused = !_isPaused;
if (_isPaused)
{
_gameTimer.Stop();
PauseOverlay.IsVisible = true;
}
else
{
_lastTickTime = DateTime.UtcNow;
_gameTimer.Start();
PauseOverlay.IsVisible = false;
}
UpdateHud();
}
private void RestartGame()
{
ResetGame();
StartGame();
@@ -58,119 +255,48 @@ public partial class GameView : UserControl
private void ResetGame()
{
_game = new SnakeGame(BoardWidth, BoardHeight);
_isRunning = false;
_timer.Stop();
UpdateHud();
RenderBoard();
}
_isPaused = false;
_gameTimer.Stop();
_game = new SnakeGame(BoardWidth, BoardHeight);
_lastTickTime = DateTime.UtcNow;
_scorePopupValue = 0;
_scorePopupElapsed = 0;
private void StartGame()
{
_isRunning = true;
_timer.Interval = TimeSpan.FromMilliseconds(SnakeGame.GetTickIntervalMs(_game.Score, BaseTickMs));
_timer.Start();
UpdateHud();
Focus();
}
StartOverlay.IsVisible = false;
PauseOverlay.IsVisible = false;
GameOverOverlay.IsVisible = false;
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);
}
SnakeCanvas.InterpolationT = 0;
SnakeCanvas.TotalElapsedSeconds = 0;
UpdateHud();
RenderBoard();
}
private void OnKeyDown(object? sender, KeyEventArgs e)
private void ShowGameOver()
{
if (!_isRunning && !_game.IsGameOver && e.Key is Key.Up or Key.Down or Key.Left or Key.Right)
{
StartGame();
}
GameOverOverlay.IsVisible = true;
GameOverScoreText.Text = $"Score: {_game.Score}";
GameOverHighScoreText.Text = _game.Score >= _highScore
? "🏆 NEW HIGH SCORE!"
: $"High Score: {_highScore}";
}
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 CloseWindow()
{
if (VisualRoot is Window window)
window.Close();
}
private void UpdateHud()
{
ScoreText.Text = $"Score: {_game.Score}";
LevelText.Text = $"Level: {_game.Level}";
ScoreText.Text = $"{_game.Score}";
LevelText.Text = $"Level {_game.Level}";
HighScoreText.Text = $"{_highScore}";
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);
// Speed bar width proportional to level
var maxLevel = 15;
var levelFraction = Math.Min((double)_game.Level / maxLevel, 1.0);
SpeedBar.Width = 4 + levelFraction * 52; // 4-56px range
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
namespace Snake.Avalonia.Views;
/// <summary>
/// Persists high score to a JSON file in the user's local app data.
/// </summary>
public static class HighScoreManager
{
private static readonly string FilePath;
static HighScoreManager()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dir = Path.Combine(appData, "SnakeAvalonia");
Directory.CreateDirectory(dir);
FilePath = Path.Combine(dir, "highscore.json");
}
public static int Load()
{
try
{
if (File.Exists(FilePath))
{
var json = File.ReadAllText(FilePath);
var data = JsonSerializer.Deserialize<HighScoreData>(json);
return data?.Score ?? 0;
}
}
catch
{
// Corrupted file — reset
}
return 0;
}
public static void Save(int score)
{
try
{
var data = new HighScoreData { Score = score, Date = DateTime.UtcNow.ToString("O") };
var json = JsonSerializer.Serialize(data);
File.WriteAllText(FilePath, json);
}
catch
{
// Silently fail — high score is non-critical
}
}
private sealed class HighScoreData
{
public int Score { get; set; }
public string Date { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,386 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Snake.Core;
namespace Snake.Avalonia.Views;
/// <summary>
/// Custom control that renders the snake, food, and grid using Avalonia's DrawingContext.
/// All rendering is done in the Render override for maximum performance at 60fps.
/// </summary>
public sealed class SnakeRenderer : Control
{
// Grid
public int BoardWidth { get; set; } = 20;
public int BoardHeight { get; set; } = 15;
public double CellSize { get; set; } = 28;
// Snake state
public IReadOnlyList<Position>? Segments { get; set; }
public IReadOnlyList<Position>? PreviousSegments { get; set; }
public Direction SnakeDirection { get; set; } = Direction.Right;
// Food state
public Position FoodPosition { get; set; }
public bool FoodJustEaten { get; set; }
// Animation
public double InterpolationT { get; set; }
public double TotalElapsedSeconds { get; set; }
public double FoodEatenElapsed { get; set; }
// Score popup
public int ScorePopup { get; set; }
public double ScorePopupElapsed { get; set; }
public Position? ScorePopupPosition { get; set; }
// Colors
private static readonly IBrush BackgroundBrush = Brush.Parse("#0D1117");
private static readonly IBrush GridDotBrush = Brush.Parse("#1E3A3A");
private static readonly ISolidColorBrush SnakeHeadBrush = (ISolidColorBrush)Brush.Parse("#14B8A6");
private static readonly ISolidColorBrush SnakeBodyBrush = (ISolidColorBrush)Brush.Parse("#0D9488");
private static readonly ISolidColorBrush SnakeTailBrush = (ISolidColorBrush)Brush.Parse("#0F766E");
private static readonly ISolidColorBrush EyeWhiteBrush = Brushes.White;
private static readonly ISolidColorBrush EyePupilBrush = (ISolidColorBrush)Brush.Parse("#0D1117");
private static readonly ISolidColorBrush FoodBrush = (ISolidColorBrush)Brush.Parse("#EF4444");
private static readonly ISolidColorBrush FoodGlowBrush = (ISolidColorBrush)Brush.Parse("#F87171");
private static readonly ISolidColorBrush FoodHighlightBrush = (ISolidColorBrush)Brush.Parse("#FCA5A5");
private static readonly ISolidColorBrush ScorePopupBrush = (ISolidColorBrush)Brush.Parse("#FBBF24");
private static readonly ISolidColorBrush TrailBrush = (ISolidColorBrush)Brush.Parse("#0F766E");
private static readonly IPen GridDotPen = new Pen(GridDotBrush, 2);
private static readonly IPen ScorePopupPen = new Pen(ScorePopupBrush, 1);
static SnakeRenderer()
{
// We invalidate manually from the render timer, no AffectsRender needed
}
public override void Render(DrawingContext context)
{
var width = Bounds.Width;
var height = Bounds.Height;
// Background
context.FillRectangle(BackgroundBrush, new Rect(0, 0, width, height));
// Draw grid (subtle dots)
DrawGrid(context, width, height);
// Draw trail
if (Segments != null && PreviousSegments != null && PreviousSegments.Count > 0)
DrawTrail(context);
// Draw food
DrawFood(context);
// Draw snake
if (Segments != null && Segments.Count > 0)
DrawSnake(context);
// Draw score popup
if (ScorePopup > 0 && ScorePopupElapsed < 1.5)
DrawScorePopup(context);
}
private void DrawGrid(DrawingContext context, double width, double height)
{
for (var x = 0; x < BoardWidth; x++)
{
for (var y = 0; y < BoardHeight; y++)
{
var cx = x * CellSize + CellSize * 0.5;
var cy = y * CellSize + CellSize * 0.5;
context.DrawEllipse(GridDotBrush, null, new Point(cx, cy), 1.0, 1.0);
}
}
// Subtle border
var borderRect = new Rect(0, 0, BoardWidth * CellSize, BoardHeight * CellSize);
context.DrawRectangle(new Pen(Brush.Parse("#1E3A3A"), 1), borderRect);
}
private void DrawTrail(DrawingContext context)
{
if (Segments == null || PreviousSegments == null || PreviousSegments.Count == 0)
return;
// Draw a subtle trail for the last few segments behind the snake
var trailLength = Math.Min(3, PreviousSegments.Count);
for (var i = 0; i < trailLength; i++)
{
var prevIdx = PreviousSegments.Count - 1 - i;
if (prevIdx < 0) break;
var pos = PreviousSegments[prevIdx];
var centerX = pos.X * CellSize + CellSize * 0.5;
var centerY = pos.Y * CellSize + CellSize * 0.5;
var radius = CellSize * 0.3 * (1.0 - i * 0.3);
var opacity = 0.15 - i * 0.05;
if (opacity <= 0) break;
context.DrawEllipse(
new SolidColorBrush(TrailBrush.Color, opacity),
null,
new Point(centerX, centerY),
radius, radius);
}
}
private void DrawSnake(DrawingContext context)
{
if (Segments == null || Segments.Count == 0) return;
var t = InterpolationT;
var segmentsList = Segments;
var prevList = PreviousSegments;
// Build path for body (all segments except head)
var bodyGeometry = new StreamGeometry();
using (var ctx = bodyGeometry.Open())
{
for (var i = segmentsList.Count - 1; i >= 1; i--)
{
var current = segmentsList[i];
var (vx, vy) = GetVisualPosition(current, i, t);
if (i == segmentsList.Count - 1 && segmentsList.Count > 2)
{
ctx.BeginFigure(new Point(vx, vy), false);
}
else if (i == segmentsList.Count - 1)
{
ctx.BeginFigure(new Point(vx, vy), false);
}
// Draw line segment
if (i > 1)
{
var (px, py) = GetVisualPosition(segmentsList[i - 1], i - 1, t);
ctx.LineTo(new Point(px, py));
}
}
if (segmentsList.Count >= 2)
{
var (hx, hy) = GetVisualPosition(segmentsList[0], 0, t);
ctx.LineTo(new Point(hx, hy));
}
}
// Calculate body thickness based on segment position (tail thinner)
var maxThickness = CellSize * 0.7;
var minThickness = CellSize * 0.35;
// Draw body as a thick stroke
var bodyThickness = Math.Max(minThickness, maxThickness * 0.85);
var bodyPen = new Pen(SnakeBodyBrush, bodyThickness, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, bodyPen, bodyGeometry);
// Draw a slightly thinner inner stroke for gradient effect
if (segmentsList.Count >= 2)
{
var innerPen = new Pen(
new SolidColorBrush(SnakeHeadBrush.Color, 0.4),
bodyThickness * 0.5,
lineCap: PenLineCap.Round,
lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, innerPen, bodyGeometry);
}
// Draw head
DrawHead(context, t);
// Draw eyes
DrawEyes(context, t);
}
private void DrawHead(DrawingContext context, double t)
{
if (Segments == null || Segments.Count == 0) return;
var (cx, cy) = GetVisualPosition(Segments[0], 0, t);
var dir = SnakeDirection;
var headSize = CellSize * 0.45;
// Rounded triangle/arrow pointing in movement direction
var headGeo = new StreamGeometry();
using (var ctx = headGeo.Open())
{
Point tip, left, right;
switch (dir)
{
case Direction.Right:
tip = new Point(cx + headSize, cy);
left = new Point(cx - headSize * 0.7, cy - headSize * 0.7);
right = new Point(cx - headSize * 0.7, cy + headSize * 0.7);
break;
case Direction.Left:
tip = new Point(cx - headSize, cy);
left = new Point(cx + headSize * 0.7, cy - headSize * 0.7);
right = new Point(cx + headSize * 0.7, cy + headSize * 0.7);
break;
case Direction.Up:
tip = new Point(cx, cy - headSize);
left = new Point(cx + headSize * 0.7, cy + headSize * 0.7);
right = new Point(cx - headSize * 0.7, cy + headSize * 0.7);
break;
case Direction.Down:
tip = new Point(cx, cy + headSize);
left = new Point(cx - headSize * 0.7, cy - headSize * 0.7);
right = new Point(cx + headSize * 0.7, cy - headSize * 0.7);
break;
default:
tip = new Point(cx + headSize, cy);
left = new Point(cx - headSize * 0.7, cy - headSize * 0.7);
right = new Point(cx - headSize * 0.7, cy + headSize * 0.7);
break;
}
ctx.BeginFigure(tip, true);
ctx.LineTo(left);
ctx.LineTo(right);
ctx.LineTo(tip); // Close back
}
// Fill head with gradient
var headFill = new SolidColorBrush(SnakeHeadBrush.Color);
context.DrawGeometry(headFill, null, headGeo);
// Highlight edge
var headEdgePen = new Pen(
new SolidColorBrush(SnakeHeadBrush.Color, 0.8),
1.5,
lineCap: PenLineCap.Round,
lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, headEdgePen, headGeo);
}
private void DrawEyes(DrawingContext context, double t)
{
if (Segments == null || Segments.Count == 0) return;
var (cx, cy) = GetVisualPosition(Segments[0], 0, t);
var dir = SnakeDirection;
// Eye offset from center based on direction
var eyeOffset = CellSize * 0.22;
var eyeRadius = CellSize * 0.1;
var pupilRadius = eyeRadius * 0.55;
(double ex1, double ey1, double ex2, double ey2) = dir switch
{
Direction.Right => (cx + eyeOffset * 0.3, cy - eyeOffset, cx + eyeOffset * 0.3, cy + eyeOffset),
Direction.Left => (cx - eyeOffset * 0.3, cy - eyeOffset, cx - eyeOffset * 0.3, cy + eyeOffset),
Direction.Up => (cx - eyeOffset, cy - eyeOffset * 0.3, cx + eyeOffset, cy - eyeOffset * 0.3),
Direction.Down => (cx - eyeOffset, cy + eyeOffset * 0.3, cx + eyeOffset, cy + eyeOffset * 0.3),
_ => (cx + eyeOffset * 0.3, cy - eyeOffset, cx + eyeOffset * 0.3, cy + eyeOffset),
};
// White of eyes
context.DrawEllipse(EyeWhiteBrush, null, new Point(ex1, ey1), eyeRadius, eyeRadius);
context.DrawEllipse(EyeWhiteBrush, null, new Point(ex2, ey2), eyeRadius, eyeRadius);
// Pupils (slightly offset towards movement direction)
var pupilShiftX = dir switch { Direction.Right => 1.5, Direction.Left => -1.5, _ => 0 };
var pupilShiftY = dir switch { Direction.Down => 1.5, Direction.Up => -1.5, _ => 0 };
context.DrawEllipse(EyePupilBrush, null,
new Point(ex1 + pupilShiftX, ey1 + pupilShiftY), pupilRadius, pupilRadius);
context.DrawEllipse(EyePupilBrush, null,
new Point(ex2 + pupilShiftX, ey2 + pupilShiftY), pupilRadius, pupilRadius);
}
private void DrawFood(DrawingContext context)
{
var pos = FoodPosition;
var cx = pos.X * CellSize + CellSize * 0.5;
var cy = pos.Y * CellSize + CellSize * 0.5;
var baseRadius = CellSize * 0.38;
// Pulsating glow
var glowRadius = AnimationHelper.PulsateRadius(baseRadius * 1.6, TotalElapsedSeconds, 0.12, 2.5);
var glowBrush = new SolidColorBrush(FoodGlowBrush.Color, 0.25);
context.DrawEllipse(glowBrush, null, new Point(cx, cy), glowRadius, glowRadius);
// Outer glow ring
var outerGlow = new SolidColorBrush(FoodGlowBrush.Color, 0.15);
context.DrawEllipse(outerGlow, null, new Point(cx, cy), glowRadius * 1.3, glowRadius * 1.3);
// Main food circle
var foodRadius = AnimationHelper.PulsateRadius(baseRadius, TotalElapsedSeconds, 0.06, 2.5);
context.DrawEllipse(FoodBrush, null, new Point(cx, cy), foodRadius, foodRadius);
// Highlight shine
var highlightX = cx - foodRadius * 0.35;
var highlightY = cy - foodRadius * 0.35;
var highlightRadius = foodRadius * 0.3;
context.DrawEllipse(FoodHighlightBrush, null,
new Point(highlightX, highlightY), highlightRadius, highlightRadius);
// Small green leaf/stem on top
var stemBaseX = cx;
var stemBaseY = cy - foodRadius;
var stemTipX = cx + foodRadius * 0.15;
var stemTipY = cy - foodRadius - CellSize * 0.18;
var stemGeo = new StreamGeometry();
using (var ctx = stemGeo.Open())
{
ctx.BeginFigure(new Point(stemBaseX - 1.5, stemBaseY), false);
ctx.LineTo(new Point(stemTipX, stemTipY));
ctx.LineTo(new Point(stemBaseX + 2, stemBaseY + 1));
ctx.LineTo(new Point(stemBaseX - 1, stemBaseY + 2));
}
context.DrawGeometry(Brush.Parse("#22C55E"), null, stemGeo);
// Small leaf
var leafGeo = new StreamGeometry();
using (var ctx = leafGeo.Open())
{
ctx.BeginFigure(new Point(stemTipX, stemTipY), false);
ctx.LineTo(new Point(stemTipX + CellSize * 0.1, stemTipY - CellSize * 0.06));
ctx.LineTo(new Point(stemTipX + CellSize * 0.05, stemTipY + CellSize * 0.02));
}
context.DrawGeometry(Brush.Parse("#22C55E"), null, leafGeo);
}
private void DrawScorePopup(DrawingContext context)
{
if (!ScorePopupPosition.HasValue) return;
var pos = ScorePopupPosition.Value;
var cx = pos.X * CellSize + CellSize * 0.5;
var cy = pos.Y * CellSize + CellSize * 0.5;
var opacity = AnimationHelper.FadeOutOpacity(ScorePopupElapsed, 1.2);
if (opacity <= 0) return;
var offsetY = ScorePopupElapsed * CellSize * -2.0; // Float upward
var y = cy + offsetY;
var text = $"+{ScorePopup}";
var formattedText = new FormattedText(
text,
System.Globalization.CultureInfo.InvariantCulture,
FlowDirection.LeftToRight,
new Typeface("Inter, Arial, sans-serif"),
CellSize * 0.7,
new SolidColorBrush(ScorePopupBrush.Color, opacity * 0.9));
var textX = cx - formattedText.Width / 2;
context.DrawText(formattedText, new Point(textX, y - formattedText.Height / 2));
}
private (double X, double Y) GetVisualPosition(Position pos, int segmentIndex, double t)
{
if (Segments == null)
return (pos.X * CellSize + CellSize * 0.5, pos.Y * CellSize + CellSize * 0.5);
var from = PreviousSegments != null && PreviousSegments.Count > 0
? AnimationHelper.GetPreviousPosition(segmentIndex, Segments, PreviousSegments, SnakeDirection)
: pos;
var useEasing = segmentIndex == 0; // Head gets easing for snappy feel
return AnimationHelper.InterpolatePosition(from, pos, t, CellSize, useEasing);
}
}

View File

@@ -15,18 +15,28 @@ public sealed class Snake
var offset = DirectionToOffset(direction);
for (var i = 0; i < length; i++)
{
_segments.AddLast(new Position(
var pos = new Position(
start.X - offset.X * i,
start.Y - offset.Y * i));
start.Y - offset.Y * i);
_segments.AddLast(pos);
_previousSegments.Add(pos);
}
}
private readonly List<Position> _previousSegments = new();
public Direction Direction { get; private set; }
public Position Head => _segments.First!.Value;
public IReadOnlyCollection<Position> Segments => _segments;
/// <summary>
/// Segment positions before the last Move() call, used for interpolation.
/// Empty (Count == 0) before first move.
/// </summary>
public IReadOnlyList<Position> PreviousSegments => _previousSegments;
/// <summary>
/// Enqueues a direction change to be applied on a future tick.
/// </summary>
@@ -56,6 +66,11 @@ public sealed class Snake
if (_pendingDirections.Count > 0)
Direction = _pendingDirections.Dequeue();
// Capture current segments as "previous" for interpolation
_previousSegments.Clear();
foreach (var seg in _segments)
_previousSegments.Add(seg);
var offset = DirectionToOffset(Direction);
var newHead = new Position(Head.X + offset.X, Head.Y + offset.Y);