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:
386
Snake.Avalonia/Views/SnakeRenderer.cs
Normal file
386
Snake.Avalonia/Views/SnakeRenderer.cs
Normal file
@@ -0,0 +1,386 @@
|
||||
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 prevList = PreviousSegments;
|
||||
|
||||
// Build path for body (all segments except head)
|
||||
var bodyGeometry = new StreamGeometry();
|
||||
using (var ctx = bodyGeometry.Open())
|
||||
{
|
||||
for (var i = segmentsList.Count - 1; i >= 1; i--)
|
||||
{
|
||||
var current = segmentsList[i];
|
||||
var (vx, vy) = GetVisualPosition(current, i, t);
|
||||
|
||||
if (i == segmentsList.Count - 1 && segmentsList.Count > 2)
|
||||
{
|
||||
ctx.BeginFigure(new Point(vx, vy), false);
|
||||
}
|
||||
else if (i == segmentsList.Count - 1)
|
||||
{
|
||||
ctx.BeginFigure(new Point(vx, vy), false);
|
||||
}
|
||||
|
||||
// Draw line segment
|
||||
if (i > 1)
|
||||
{
|
||||
var (px, py) = GetVisualPosition(segmentsList[i - 1], i - 1, t);
|
||||
ctx.LineTo(new Point(px, py));
|
||||
}
|
||||
}
|
||||
|
||||
if (segmentsList.Count >= 2)
|
||||
{
|
||||
var (hx, hy) = GetVisualPosition(segmentsList[0], 0, t);
|
||||
ctx.LineTo(new Point(hx, hy));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate body thickness based on segment position (tail thinner)
|
||||
var maxThickness = CellSize * 0.7;
|
||||
var minThickness = CellSize * 0.35;
|
||||
|
||||
// Draw body as a thick stroke
|
||||
var bodyThickness = Math.Max(minThickness, maxThickness * 0.85);
|
||||
var bodyPen = new Pen(SnakeBodyBrush, bodyThickness, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round);
|
||||
context.DrawGeometry(null, bodyPen, bodyGeometry);
|
||||
|
||||
// Draw a slightly thinner inner stroke for gradient effect
|
||||
if (segmentsList.Count >= 2)
|
||||
{
|
||||
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 from = PreviousSegments != null && PreviousSegments.Count > 0
|
||||
? AnimationHelper.GetPreviousPosition(segmentIndex, Segments, PreviousSegments, SnakeDirection)
|
||||
: pos;
|
||||
|
||||
var useEasing = segmentIndex == 0; // Head gets easing for snappy feel
|
||||
return AnimationHelper.InterpolatePosition(from, pos, t, CellSize, useEasing);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user