diff --git a/Snake.Avalonia/App.axaml b/Snake.Avalonia/App.axaml
index 2e46121..bda64e1 100644
--- a/Snake.Avalonia/App.axaml
+++ b/Snake.Avalonia/App.axaml
@@ -1,10 +1,38 @@
-
+ RequestedThemeVariant="Dark">
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/Snake.Avalonia/MainWindow.axaml b/Snake.Avalonia/MainWindow.axaml
index 7571fae..77d2c5d 100644
--- a/Snake.Avalonia/MainWindow.axaml
+++ b/Snake.Avalonia/MainWindow.axaml
@@ -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">
diff --git a/Snake.Avalonia/MainWindow.axaml.cs b/Snake.Avalonia/MainWindow.axaml.cs
index e6b141b..ae99fac 100644
--- a/Snake.Avalonia/MainWindow.axaml.cs
+++ b/Snake.Avalonia/MainWindow.axaml.cs
@@ -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;
+ }
+ };
}
-}
\ No newline at end of file
+}
diff --git a/Snake.Avalonia/Views/AnimationHelper.cs b/Snake.Avalonia/Views/AnimationHelper.cs
new file mode 100644
index 0000000..55426a1
--- /dev/null
+++ b/Snake.Avalonia/Views/AnimationHelper.cs
@@ -0,0 +1,105 @@
+using Snake.Core;
+
+namespace Snake.Avalonia.Views;
+
+///
+/// Interpolation helpers for smooth snake animation.
+///
+public static class AnimationHelper
+{
+ ///
+ /// Ease-out cubic for smooth deceleration (head overshoot feel).
+ ///
+ public static double EaseOutCubic(double t)
+ {
+ t = Math.Clamp(t, 0.0, 1.0);
+ return 1.0 - Math.Pow(1.0 - t, 3);
+ }
+
+ ///
+ /// Ease-in-out quad for general smooth interpolation.
+ ///
+ 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;
+ }
+
+ ///
+ /// Interpolates between two positions with given t value.
+ /// Returns the visual position for rendering.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ public static Position GetPreviousPosition(
+ int segmentIndex,
+ IReadOnlyList currentSegments,
+ IReadOnlyList 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];
+ }
+
+ ///
+ /// Computes a pulsating radius for food glow effect.
+ ///
+ 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));
+ }
+
+ ///
+ /// Computes opacity for score popup fade-out animation.
+ ///
+ public static double FadeOutOpacity(double elapsedSeconds, double durationSeconds = 1.0)
+ {
+ var t = Math.Clamp(elapsedSeconds / durationSeconds, 0.0, 1.0);
+ return 1.0 - t;
+ }
+}
diff --git a/Snake.Avalonia/Views/GameView.axaml b/Snake.Avalonia/Views/GameView.axaml
index bc68e28..88fe6e4 100644
--- a/Snake.Avalonia/Views/GameView.axaml
+++ b/Snake.Avalonia/Views/GameView.axaml
@@ -1,30 +1,175 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Snake.Avalonia/Views/GameView.axaml.cs b/Snake.Avalonia/Views/GameView.axaml.cs
index 06732ad..069e961 100644
--- a/Snake.Avalonia/Views/GameView.axaml.cs
+++ b/Snake.Avalonia/Views/GameView.axaml.cs
@@ -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
}
}
diff --git a/Snake.Avalonia/Views/HighScoreManager.cs b/Snake.Avalonia/Views/HighScoreManager.cs
new file mode 100644
index 0000000..cb7897c
--- /dev/null
+++ b/Snake.Avalonia/Views/HighScoreManager.cs
@@ -0,0 +1,58 @@
+using System.Text.Json;
+
+namespace Snake.Avalonia.Views;
+
+///
+/// Persists high score to a JSON file in the user's local app data.
+///
+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(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;
+ }
+}
diff --git a/Snake.Avalonia/Views/SnakeRenderer.cs b/Snake.Avalonia/Views/SnakeRenderer.cs
new file mode 100644
index 0000000..11a60f0
--- /dev/null
+++ b/Snake.Avalonia/Views/SnakeRenderer.cs
@@ -0,0 +1,386 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Snake.Core;
+
+namespace Snake.Avalonia.Views;
+
+///
+/// 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.
+///
+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? Segments { get; set; }
+ public IReadOnlyList? 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);
+ }
+}
diff --git a/Snake.Core/Snake.cs b/Snake.Core/Snake.cs
index fd24a66..ac422c4 100644
--- a/Snake.Core/Snake.cs
+++ b/Snake.Core/Snake.cs
@@ -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 _previousSegments = new();
+
public Direction Direction { get; private set; }
public Position Head => _segments.First!.Value;
public IReadOnlyCollection Segments => _segments;
+ ///
+ /// Segment positions before the last Move() call, used for interpolation.
+ /// Empty (Count == 0) before first move.
+ ///
+ public IReadOnlyList PreviousSegments => _previousSegments;
+
///
/// Enqueues a direction change to be applied on a future tick.
///
@@ -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);