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,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
}
}