18 Commits

Author SHA1 Message Date
Heller
90fd0f592f fix: virtual grid-snapped tail anchor prevents corner diagonals
Body path now goes: interp_tail → grid_tail → segment[N-2] → ... → head.
The short leg from interpolated to grid-snapped tail is always straight
(same direction), and from grid_tail onward the path strictly follows
the grid — preserving sharp corners while tail stays smooth.
2026-06-19 10:27:42 +00:00
Heller
c1b9543c47 feat: tail interpolates like head (mirrored with ease-in)
Tail now uses the same approach as head: interpolated in the body path
from its previous position, with ease-in (mirror of head's ease-out).
Ghost circle removed — no longer needed since tail is naturally smooth.
Added EaseInCubic to AnimationHelper. InterpolatePosition now uses
string easing mode instead of bool.
2026-06-19 10:25:31 +00:00
Heller
36bfe5ad45 fix: tail as full body segment + fading ghost for smooth illusion
Tail is now part of the main body path (grid-snapped, full thickness,
sharp corners). Smooth movement illusion via fading ghost circle at
the old tail position that fades out during the tick.
2026-06-19 10:22:33 +00:00
Heller
d83234c8be fix: smooth tail via separate line segment, body path stays grid-snapped
Tail is now drawn as a separate DrawLine from interpolated tail position
to the grid-snapped second-to-last segment. Body path (segment[N-2]→head)
stays grid-snapped, preserving sharp corners while tail glides smoothly.
2026-06-19 10:18:26 +00:00
Heller
4446bd778f fix: interpolate only head, pin all other segments to grid
Tail interpolation caused diagonal shortcuts at corners because
the interpolated tail position would drift while the adjacent
segment was already at its new grid position. By keeping only
the head interpolated, L-shaped bends render correctly.
2026-06-19 10:14:51 +00:00
Heller
cb06980cfb fix: preserve sharp corners during smooth animation 2026-06-19 10:12:32 +00:00
Heller
d483cd0660 fix: only interpolate head and tail, keep middle segments grid-snapped
Prevents diagonal shortcuts when snake bends — middle segments stay
at exact cell centers so L-shaped corners render correctly.
2026-06-19 10:12:32 +00:00
Heller
5da42c119e chore: gitignore publish directory 2026-06-19 10:09:12 +00:00
Heller
9dd304811b fix: smooth tail interpolation 2026-06-19 10:08:56 +00:00
Heller
dcb65831ac fix: smooth tail interpolation for non-growing snake
When snake doesn't grow, the last segment (new tail) should interpolate
from the old tail's position (previous[segmentIndex]), not from the
second-to-last segment (previous[segmentIndex-1]) which didn't move.
2026-06-19 10:08:56 +00:00
Heller
aa2016cc40 feat: smooth snake animation + beautiful Avalonia UI 2026-06-19 10:02:11 +00:00
Heller
7d55a08380 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
2026-06-19 10:02:07 +00:00
Heller
2e89c6dca3 fix: implement direction input queue for rapid key presses 2026-06-17 19:16:05 +00:00
Heller
06c51277b6 fix: replace pending direction with input queue to preserve all rapid key presses 2026-06-17 19:16:03 +00:00
Heller
76b9faba1a fix: rapid key input causing self-collision 2026-06-17 19:12:23 +00:00
Heller
f02e389580 fix: prevent self-collision on rapid multi-key input using pending direction buffer 2026-06-17 19:12:20 +00:00
Heller
7330880c96 fix: correct tail collision logic in SnakeGame and Snake 2026-06-17 19:04:32 +00:00
Heller
0d9710965c feat: fully functional CLI and Avalonia UI for Snake game 2026-06-17 19:03:22 +00:00
13 changed files with 1149 additions and 33 deletions

1
.gitignore vendored
View File

@@ -64,3 +64,4 @@ Thumbs.db
## Avalonia
*.DotSettings.user
publish/

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,8 +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="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="540"
x:Class="Snake.Avalonia.MainWindow"
Title="Snake">
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,121 @@
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>
/// Ease-in cubic — mirror of EaseOutCubic, for tail interpolation.
/// </summary>
public static double EaseInCubic(double t)
{
t = Math.Clamp(t, 0.0, 1.0);
return t * t * t;
}
/// <summary>
/// Interpolates between two positions with given t value and easing mode.
/// Returns the visual position for rendering.
/// </summary>
public static (double X, double Y) InterpolatePosition(
Position from,
Position to,
double t,
double cellSize,
string easing = "linear")
{
var eased = easing switch
{
"easeOut" => EaseOutCubic(t),
"easeIn" => EaseInCubic(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, … current[N-2] = old[N-3]
// BUT the last segment (tail): it was old[N-1] (the removed tail's position)
// because visually it slides from where the old tail was.
var isLast = segmentIndex == currentSegments.Count - 1;
var prevIdx = isLast ? segmentIndex : 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,7 +1,175 @@
<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" />
xmlns:views="using:Snake.Avalonia.Views"
x:Class="Snake.Avalonia.Views.GameView"
Background="#0D1117"
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>
<!-- 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,15 +1,302 @@
using Avalonia;
using Avalonia.Controls;
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 int BaseTickMs = 120;
private SnakeGame _game = new(BoardWidth, BoardHeight);
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();
// Load high score
_highScore = HighScoreManager.Load();
// Game timer — ticks the game logic
_gameTimer = new DispatcherTimer();
_gameTimer.Tick += OnGameTick;
// 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();
// Start render loop immediately (for idle animation)
_lastRenderTime = DateTime.UtcNow;
_renderTimer.Start();
}
private void OnGlobalKeyDown(object? sender, KeyEventArgs e)
{
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;
_lastTickTime = DateTime.UtcNow;
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 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();
}
private void ResetGame()
{
_isRunning = false;
_isPaused = false;
_gameTimer.Stop();
_game = new SnakeGame(BoardWidth, BoardHeight);
_lastTickTime = DateTime.UtcNow;
_scorePopupValue = 0;
_scorePopupElapsed = 0;
StartOverlay.IsVisible = false;
PauseOverlay.IsVisible = false;
GameOverOverlay.IsVisible = false;
SnakeCanvas.InterpolationT = 0;
SnakeCanvas.TotalElapsedSeconds = 0;
UpdateHud();
}
private void ShowGameOver()
{
GameOverOverlay.IsVisible = true;
GameOverScoreText.Text = $"Score: {_game.Score}";
GameOverHighScoreText.Text = _game.Score >= _highScore
? "🏆 NEW HIGH SCORE!"
: $"High Score: {_highScore}";
}
private void CloseWindow()
{
if (VisualRoot is Window window)
window.Close();
}
private void UpdateHud()
{
ScoreText.Text = $"{_game.Score}";
LevelText.Text = $"Level {_game.Level}";
HighScoreText.Text = $"{_highScore}";
// 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,383 @@
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 count = segmentsList.Count;
// === Body path: from interp tail → grid tail (virtual corner anchor) → body → head ===
// The virtual grid-snapped tail prevents diagonal shortcuts at corners:
// the short leg from interp_tail to grid_tail is always straight (same direction),
// and from grid_tail onward the path strictly follows the grid.
if (count >= 2)
{
var bodyGeometry = new StreamGeometry();
using (var ctx = bodyGeometry.Open())
{
// Start at interpolated tail
var (tailX, tailY) = GetVisualPosition(segmentsList[count - 1], count - 1, t);
ctx.BeginFigure(new Point(tailX, tailY), false);
// Virtual corner anchor: grid-snapped tail position
var lastPos = segmentsList[count - 1];
var (gridTailX, gridTailY) = (lastPos.X * CellSize + CellSize * 0.5, lastPos.Y * CellSize + CellSize * 0.5);
ctx.LineTo(new Point(gridTailX, gridTailY));
// Draw through middle segments down to head
for (var i = count - 2; i >= 0; i--)
{
var (px, py) = GetVisualPosition(segmentsList[i], i, t);
ctx.LineTo(new Point(px, py));
}
}
// Body thickness
var maxThickness = CellSize * 0.7;
var bodyThickness = Math.Max(CellSize * 0.35, maxThickness * 0.85);
var bodyPen = new Pen(SnakeBodyBrush, bodyThickness,
lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
context.DrawGeometry(null, bodyPen, bodyGeometry);
// Inner highlight stroke
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 isHead = segmentIndex == 0;
var isTail = Segments.Count > 1 && segmentIndex == Segments.Count - 1;
// Head and tail interpolate smoothly; middle segments stay grid-snapped
if (!isHead && !isTail)
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;
// Head: ease-out forward. Tail: ease-in backward (mirrored).
var easingMode = isHead ? "easeOut" : "easeIn";
return AnimationHelper.InterpolatePosition(from, pos, t, CellSize, easing: easingMode);
}
}

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

@@ -3,6 +3,7 @@ namespace Snake.Core;
public sealed class Snake
{
private readonly LinkedList<Position> _segments = new();
private readonly Queue<Direction> _pendingDirections = new();
public Snake(Position start, int length, Direction direction)
{
@@ -14,38 +15,64 @@ 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>
public void SetDirection(Direction direction)
{
if (direction == Direction)
return;
if (AreOpposite(Direction, direction))
return;
Direction = direction;
_pendingDirections.Enqueue(direction);
}
/// <summary>
/// Returns the head position after the next move without changing state.
/// Invalid queued directions (same as current or opposite) are discarded.
/// </summary>
public Position PeekNextHead()
{
var offset = DirectionToOffset(Direction);
DiscardInvalidPendingDirections();
var direction = _pendingDirections.Count > 0 ? _pendingDirections.Peek() : Direction;
var offset = DirectionToOffset(direction);
return new Position(Head.X + offset.X, Head.Y + offset.Y);
}
/// <summary>
/// Applies at most one valid queued direction change, then moves the snake.
/// </summary>
public Position Move(bool grow = false)
{
var newHead = PeekNextHead();
DiscardInvalidPendingDirections();
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);
_segments.AddFirst(newHead);
@@ -55,8 +82,28 @@ public sealed class Snake
return newHead;
}
public bool Occupies(Position position) =>
_segments.Contains(position);
public bool Occupies(Position position, bool excludeTail = false)
{
if (excludeTail && _segments.Last is { } last && last.Value == position)
return false;
return _segments.Contains(position);
}
/// <summary>
/// Removes queued directions that would turn backwards or repeat the current direction.
/// </summary>
private void DiscardInvalidPendingDirections()
{
while (_pendingDirections.Count > 0)
{
var next = _pendingDirections.Peek();
if (next != Direction && !AreOpposite(Direction, next))
break;
_pendingDirections.Dequeue();
}
}
private static Position DirectionToOffset(Direction direction) =>
direction switch

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()
@@ -32,14 +39,13 @@ public sealed class SnakeGame
return GameTickResult.GameOver;
var newHead = Snake.PeekNextHead();
var ateFood = newHead == Food.Position;
if (!Board.IsWithinBounds(newHead) || Snake.Occupies(newHead))
if (!Board.IsWithinBounds(newHead) || Snake.Occupies(newHead, excludeTail: !ateFood))
{
IsGameOver = true;
return GameTickResult.GameOver;
}
var ateFood = newHead == Food.Position;
Snake.Move(grow: ateFood);
if (ateFood)