using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Snake.Core; namespace Snake.Avalonia.Views; /// /// 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. /// 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? Segments { get; set; } public IReadOnlyList? 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 segment[Count-1] (tail) through segment[0] (head) === // All segments use GetVisualPosition — middle/body returns grid-snapped, // head returns interpolated. Tail is grid-snapped, giving sharp corners // with full body thickness. if (count >= 2) { var bodyGeometry = new StreamGeometry(); using (var ctx = bodyGeometry.Open()) { // Start at tail (last segment, grid-snapped) var (startX, startY) = GetVisualPosition(segmentsList[count - 1], count - 1, t); ctx.BeginFigure(new Point(startX, startY), false); // 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); } }