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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user