Files
snake-csharp/Snake.Avalonia/Views/SnakeRenderer.cs
Heller 4446bd778f fix: interpolate only head, pin all other segments to grid
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.
2026-06-19 10:14:51 +00:00

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);
}
}