Scoring/SquishEfficiency.cs
using System;
using SquishyTerry.Sim;
namespace SquishyTerry.Scoring;
public enum SquishKind
{
/// <summary>The grabbed point was pulled away from the head.</summary>
Stretch,
/// <summary>The pull slid across the face and compressed it.</summary>
Squish,
}
/// <summary>
/// Judges how well each pull was executed, like a rhythm game's "PERFECT".
///
/// Every pull is classified as a <see cref="SquishKind.Stretch"/> (pulled away from the head) or a
/// <see cref="SquishKind.Squish"/> (slid across the face, squashing it), then scored 0-100% on:
/// <list type="bullet">
/// <item><b>Target (45%)</b>: did it land in the sweet spot? A big stretch that isn't absurd, or a
/// deep squish that isn't a collapse.</item>
/// <item><b>Release (30%)</b>: let go at full tension, held steady. Easing off first, or letting go
/// mid-yank, loses points.</item>
/// <item><b>Snap (25%)</b>: the original efficiency, wobble out vs effort in (a clean snap-back).</item>
/// </list>
/// 95%+ is a PERFECT STRETCH / PERFECT SQUISH.
///
/// The sweet spots and the snap par were measured in the test harness (see the "judge" mode).
/// Engine-agnostic: call these from your grab/physics code and pass in your mesh data each step.
/// </summary>
public class SquishEfficiency
{
// --- Tuning ---
public float SpringK = 1f; // match your mesh stiffness
public float MinInputEnergy = 0.02f; // ignore tiny taps (anti-exploit)
public float SettleThreshold = 0.01f; // avg vertex speed that counts as "still"
public float SnapPar = 1.1f; // wobble-out / effort-in of a clean big snap-back = full marks
public float MaxEfficiencyPercent = 100f;
// Sweet spots (head units / fraction of volume), measured in the harness.
public float StretchIdealMin = 1.5f, StretchIdealMax = 2.5f;
public float SquishIdealMin = 0.20f, SquishIdealMax = 0.35f;
// Classification: a stretch moves the point mostly outward and doesn't compress the head.
const float OutwardFraction = 0.7f;
const float CompressionForSquish = 0.03f;
// Release: steady for this many steps near the peak, and still at 95% of it on letting go.
const int SteadySteps = 6;
const float SteadySpeed = 0.01f;
// --- State ---
Vec3 _restPos, _centre;
float _faceSize;
float _restRadius;
float _peakStretch;
float _outwardAtPeak;
float _minVolume;
float _lastStretch;
float _releaseStretch;
int _steady;
int _bestSteady;
float _inputEnergy;
float _outputEnergy;
float _peakSquish;
bool _pulling;
bool _settling;
public bool IsSettling => _settling;
public bool IsPulling => _pulling;
/// <summary>Peak distance of the grabbed point from rest this pull, in face sizes.</summary>
public float PeakStretch => _peakStretch;
/// <summary>What the last pull was (valid from <see cref="Release"/>).</summary>
public SquishKind Kind { get; private set; }
/// Call when the player grabs a vertex. centre: the head's centre at rest.
public void BeginPull( Vec3 grabbedVertexRestPos, Vec3 centre, float faceSize )
{
_restPos = grabbedVertexRestPos;
_centre = centre;
_faceSize = MathF.Max( faceSize, 0.0001f );
_restRadius = (_restPos - _centre).Length;
_peakStretch = 0f;
_outwardAtPeak = 1f;
_minVolume = 1f;
_lastStretch = 0f;
_steady = _bestSteady = 0;
_outputEnergy = 0f;
_peakSquish = 0f;
_pulling = true;
_settling = false;
}
/// Call every step while held. volumeRatio: the head's volume / its rest volume.
public void UpdatePull( Vec3 grabPos, float volumeRatio )
{
if ( !_pulling ) return;
float stretch = (grabPos - _restPos).Length / _faceSize;
if ( stretch > _peakStretch )
{
_peakStretch = stretch;
float growth = ((grabPos - _centre).Length - _restRadius) / _faceSize;
_outwardAtPeak = growth / MathF.Max( stretch, 1e-4f );
}
_minVolume = MathF.Min( _minVolume, volumeRatio );
// Held steady near full tension?
bool steady = MathF.Abs( stretch - _lastStretch ) < SteadySpeed && stretch >= 0.9f * _peakStretch;
_steady = steady ? _steady + 1 : 0;
_bestSteady = Math.Max( _bestSteady, _steady );
_lastStretch = stretch;
}
/// Call when the player lets go.
public void Release()
{
if ( !_pulling ) return;
_pulling = false;
_releaseStretch = _lastStretch;
_inputEnergy = 0.5f * SpringK * _peakStretch * _peakStretch;
Kind = _minVolume < 1f - CompressionForSquish || _outwardAtPeak < OutwardFraction ? SquishKind.Squish : SquishKind.Stretch;
_settling = true;
}
/// Call every step after release until it returns a result.
/// velocities: per-vertex velocity (per second). current/rest: per-vertex positions.
/// Returns null while still wobbling.
public SquishResult? UpdateSettle( Vec3[] velocities, Vec3[] current, Vec3[] rest, float dt )
{
if ( !_settling ) return null;
int n = velocities.Length;
float kinetic = 0f;
float speedSum = 0f;
float squish = 0f;
for ( int i = 0; i < n; i++ )
{
float speed = velocities[i].Length / _faceSize;
kinetic += 0.5f * speed * speed;
speedSum += speed;
// Squish = how far each vertex is displaced from rest right now
squish += (current[i] - rest[i]).Length / _faceSize;
}
// Average per vertex so mesh resolution doesn't change scores
kinetic /= n;
squish /= n;
_outputEnergy += kinetic * dt; // dt keeps it frame-rate independent
_peakSquish = MathF.Max( _peakSquish, squish );
if ( speedSum / n > SettleThreshold ) return null;
_settling = false;
return BuildResult();
}
/// Ends the settle early (e.g. the player grabbed again before the face went still) and scores
/// what was collected so far. Most of the wobble energy arrives in the first second, so this
/// is close to the full result. Returns null if nothing was settling.
public SquishResult? FinishNow()
{
if ( !_settling ) return null;
_settling = false;
return BuildResult();
}
/// <summary>1 inside [min, max]; ramps up from 0 at <paramref name="zero"/>, eases down to <paramref name="floor"/> at <paramref name="far"/> past max.</summary>
static float Band( float x, float zero, float min, float max, float far, float floor )
{
if ( x <= zero ) return 0f;
if ( x < min ) return (x - zero) / (min - zero);
if ( x <= max ) return 1f;
float t = MathF.Min( (x - max) / (far - max), 1f );
return 1f - t * (1f - floor);
}
SquishResult BuildResult()
{
// Too small a pull: no score, stops tap-spam from farming.
if ( _inputEnergy < MinInputEnergy )
return new SquishResult( Kind, 0f, 0f, 0f, 0f, 0f, _inputEnergy, _outputEnergy, _peakSquish, "Too gentle" );
float target = Kind == SquishKind.Stretch
? Band( _peakStretch, 0.3f, StretchIdealMin, StretchIdealMax, 4f, 0.6f )
: Band( 1f - _minVolume, 0f, SquishIdealMin, SquishIdealMax, 0.55f, 0.6f );
float held = Math.Clamp( (_releaseStretch / MathF.Max( _peakStretch, 1e-4f ) - 0.6f) / 0.35f, 0f, 1f );
float release = held * (_bestSteady >= SteadySteps ? 1f : 0.85f);
float ratio = _outputEnergy / _inputEnergy;
float snap = Math.Clamp( ratio / SnapPar, 0f, 1f );
float percent = MathF.Min( 100f * (0.45f * target + 0.30f * release + 0.25f * snap), MaxEfficiencyPercent );
return new SquishResult( Kind, ratio, percent, target, release, snap, _inputEnergy, _outputEnergy, _peakSquish, Rate( percent, Kind ) );
}
static string Rate( float percent, SquishKind kind )
{
string what = kind == SquishKind.Stretch ? "stretch" : "squish";
return percent switch
{
>= 95f => $"PERFECT {what.ToUpperInvariant()}!",
>= 80f => $"Great {what}",
>= 60f => $"Good {what}",
>= 40f => $"Okay {what}",
_ => $"Sloppy {what}"
};
}
}
public readonly record struct SquishResult(
SquishKind Kind,
float Ratio, // raw wobble-out / effort-in
float Percent, // 0-100, 95+ = perfect; use for leaderboards
float Target, // 0-1: how close to the sweet spot
float Release, // 0-1: let go at full, steady tension
float Snap, // 0-1: clean snap-back (ratio vs par)
float InputEnergy,
float OutputEnergy,
float PeakSquish, // max average mesh deformation
string Rating );