- 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
303 lines
8.2 KiB
C#
303 lines
8.2 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Snake.Core;
|
|
|
|
namespace Snake.Avalonia.Views;
|
|
|
|
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
|
|
}
|
|
}
|