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:
105
Snake.Avalonia/Views/AnimationHelper.cs
Normal file
105
Snake.Avalonia/Views/AnimationHelper.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
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>
|
||||
/// Interpolates between two positions with given t value.
|
||||
/// Returns the visual position for rendering.
|
||||
/// </summary>
|
||||
public static (double X, double Y) InterpolatePosition(
|
||||
Position from,
|
||||
Position to,
|
||||
double t,
|
||||
double cellSize,
|
||||
bool useEasing = false)
|
||||
{
|
||||
var eased = useEasing ? EaseOutCubic(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, etc.
|
||||
// current[i] (i>=1) was at old[i-1]
|
||||
var prevIdx = 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user