Compare commits
18 Commits
feature/in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fd0f592f | ||
|
|
c1b9543c47 | ||
|
|
36bfe5ad45 | ||
|
|
d83234c8be | ||
|
|
4446bd778f | ||
|
|
cb06980cfb | ||
|
|
d483cd0660 | ||
|
|
5da42c119e | ||
|
|
9dd304811b | ||
|
|
dcb65831ac | ||
|
|
aa2016cc40 | ||
|
|
7d55a08380 | ||
|
|
2e89c6dca3 | ||
|
|
06c51277b6 | ||
|
|
76b9faba1a | ||
|
|
f02e389580 | ||
|
|
7330880c96 | ||
|
|
0d9710965c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,3 +64,4 @@ Thumbs.db
|
||||
|
||||
## Avalonia
|
||||
*.DotSettings.user
|
||||
publish/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
Snake.Avalonia/Views/AnimationHelper.cs
Normal file
121
Snake.Avalonia/Views/AnimationHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
58
Snake.Avalonia/Views/HighScoreManager.cs
Normal file
58
Snake.Avalonia/Views/HighScoreManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
383
Snake.Avalonia/Views/SnakeRenderer.cs
Normal file
383
Snake.Avalonia/Views/SnakeRenderer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user