Files
snake-csharp/Snake.Avalonia/Views/AnimationHelper.cs
Heller dcb65831ac fix: smooth tail interpolation for non-growing snake
When snake doesn't grow, the last segment (new tail) should interpolate
from the old tail's position (previous[segmentIndex]), not from the
second-to-last segment (previous[segmentIndex-1]) which didn't move.
2026-06-19 10:08:56 +00:00

108 lines
3.7 KiB
C#

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, … current[N-2] = old[N-3]
// BUT the last segment (tail): it was old[N-1] (the removed tail's position)
// because visually it slides from where the old tail was.
var isLast = segmentIndex == currentSegments.Count - 1;
var prevIdx = isLast ? segmentIndex : 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;
}
}