Tail interpolation caused diagonal shortcuts at corners because the interpolated tail position would drift while the adjacent segment was already at its new grid position. By keeping only the head interpolated, L-shaped bends render correctly.
391 lines
15 KiB
C#
391 lines
15 KiB
C#
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)
|
|
{
|
|
// Only interpolate the head — all other segments stay grid-snapped
|
|
// to preserve sharp corners when the snake bends.
|
|
// Interpolating the tail creates diagonal shortcuts at corners because
|
|
// the tail position drifts while the adjacent segment is already at its
|
|
// new grid position.
|
|
if (segmentIndex != 0)
|
|
return (pos.X * CellSize + CellSize * 0.5, pos.Y * CellSize + CellSize * 0.5);
|
|
|
|
var from = PreviousSegments != null && PreviousSegments.Count > 0
|
|
? AnimationHelper.GetPreviousPosition(0, Segments!, PreviousSegments, SnakeDirection)
|
|
: pos;
|
|
|
|
return AnimationHelper.InterpolatePosition(from, pos, t, CellSize, useEasing: true);
|
|
}
|
|
}
|