diff --git a/addons/openfairway/physics/BallPhysics.cs b/addons/openfairway/physics/BallPhysics.cs
index 2e72912..c13a54c 100644
--- a/addons/openfairway/physics/BallPhysics.cs
+++ b/addons/openfairway/physics/BallPhysics.cs
@@ -33,25 +33,13 @@ public partial class BallPhysics : RefCounted
[Export] public float SpinDragMultiplierMax { get => SPIN_DRAG_MULTIPLIER_MAX; private set { } }
[Export] public float SpinDragMultiplierHighSpinMax { get => SPIN_DRAG_MULTIPLIER_HIGH_SPIN_MAX; private set { } }
- // Spin friction tuning constants
- private const float CHIP_SPEED_THRESHOLD = 20.0f; // m/s — below this, reduced spin friction
- private const float PITCH_SPEED_THRESHOLD = 35.0f; // m/s — transition to full spin friction
- private const float CHIP_VELOCITY_SCALE_MIN = 0.60f; // Minimum velocity scale for chip shots
- private const float CHIP_VELOCITY_SCALE_MAX = 0.87f; // Maximum velocity scale for chip shots
- private const float LOW_SPIN_THRESHOLD = 1750.0f; // RPM — grooves don't "bite" below this
- private const float MID_SPIN_THRESHOLD = 1750.0f; // RPM — bump/pitch transition
- private const float LOW_SPIN_MULTIPLIER_MAX = 1.15f; // Friction multiplier at LOW_SPIN_THRESHOLD
- private const float MID_SPIN_MULTIPLIER_MAX = 2.25f; // Friction multiplier at MID_SPIN_THRESHOLD
- private const float HIGH_SPIN_MULTIPLIER_MAX = 2.50f; // Maximum friction multiplier (high spin wedges)
- private const float HIGH_SPIN_RAMP_RANGE = 1000.0f; // RPM range for high-spin multiplier ramp
-
- // Friction blending
- private const float FRICTION_BLEND_SPEED = 15.0f; // m/s — blending threshold for rolling/kinetic friction
- private const float TANGENT_VELOCITY_THRESHOLD = 0.05f; // m/s — below this, pure rolling (no slip)
+ private static readonly RolloutProfile DefaultRollout = RolloutProfile.Default;
// Gravity force (pre-computed to avoid per-frame allocation), yea--I came up with this :D
private static readonly Vector3 GravityForce = new(0.0f, -9.81f * MASS, 0.0f);
+ private readonly BounceCalculator _bounceCalc = new();
+
///
/// Calculate total forces acting on the ball
///
@@ -88,70 +76,8 @@ public Vector3 CalculateForces(
}
}
- ///
- /// Calculate spin-based friction multiplier
- /// High backspin causes ball to "bite" into grass, increasing effective friction
- /// Uses the IMPACT spin (when ball first landed) to determine friction,
- /// as the "bite" happens at impact, not during rolling
- ///
private float GetSpinFrictionMultiplier(Vector3 omega, float impactSpinRpm, float ballSpeed)
- {
- // Use the higher of current spin or impact spin
- // This preserves the "bite" effect even as spin decays during rollout
- float currentSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
- float effectiveSpinRpm = Mathf.Max(currentSpinRpm, impactSpinRpm);
-
- // Calculate velocity scaling factor
- // The "bite" effect from spin depends on impact energy, not just spin rate
- // Low-speed chip shots shouldn't bite as hard as high-speed wedge shots
- float velocityScale;
- if (ballSpeed < CHIP_SPEED_THRESHOLD)
- {
- // Chip/bump shots: Moderate spin friction
- velocityScale = Mathf.Lerp(CHIP_VELOCITY_SCALE_MIN, CHIP_VELOCITY_SCALE_MAX,
- ballSpeed / CHIP_SPEED_THRESHOLD);
- }
- else if (ballSpeed < PITCH_SPEED_THRESHOLD)
- {
- // Transition zone: Pitch shots
- velocityScale = Mathf.Lerp(CHIP_VELOCITY_SCALE_MAX, 1.0f,
- (ballSpeed - CHIP_SPEED_THRESHOLD) / (PITCH_SPEED_THRESHOLD - CHIP_SPEED_THRESHOLD));
- }
- else
- {
- // Full wedges: Full spin friction
- velocityScale = 1.0f;
- }
-
- // Non-linear with threshold: Grooves don't really "bite" until >LOW_SPIN_THRESHOLD rpm
- float spinMultiplier;
-
- if (effectiveSpinRpm < LOW_SPIN_THRESHOLD)
- {
- // Low spin (drivers/woods): Minimal friction increase (1.0x to LOW_SPIN_MULTIPLIER_MAX)
- spinMultiplier = 1.0f + (effectiveSpinRpm / LOW_SPIN_THRESHOLD) * (LOW_SPIN_MULTIPLIER_MAX - 1.0f);
- }
- else if (effectiveSpinRpm < MID_SPIN_THRESHOLD)
- {
- // Bump/pitch shots: Steep increase (LOW_SPIN_MULTIPLIER_MAX to MID_SPIN_MULTIPLIER_MAX)
- float excessSpin = effectiveSpinRpm - LOW_SPIN_THRESHOLD;
- float midRange = MID_SPIN_THRESHOLD - LOW_SPIN_THRESHOLD;
- spinMultiplier = LOW_SPIN_MULTIPLIER_MAX +
- (excessSpin / midRange) * (MID_SPIN_MULTIPLIER_MAX - LOW_SPIN_MULTIPLIER_MAX);
- }
- else
- {
- // High spin (wedges): Gradual increase to maximum
- float excessSpin = effectiveSpinRpm - MID_SPIN_THRESHOLD;
- float spinFactor = Mathf.Min(excessSpin / HIGH_SPIN_RAMP_RANGE, 1.0f);
- spinMultiplier = MID_SPIN_MULTIPLIER_MAX +
- spinFactor * (HIGH_SPIN_MULTIPLIER_MAX - MID_SPIN_MULTIPLIER_MAX);
- }
-
- // Apply velocity scaling to reduce spin effect for low-speed shots
- float scaledMultiplier = 1.0f + (spinMultiplier - 1.0f) * velocityScale;
- return scaledMultiplier;
- }
+ => GetSpinFrictionMultiplier(omega, impactSpinRpm, ballSpeed, DefaultRollout);
///
/// Calculate ground friction and drag forces
@@ -176,7 +102,7 @@ public Vector3 CalculateGroundForces(
Vector3 tangentVelocity = contactVelocity - parameters.FloorNormal * contactVelocity.Dot(parameters.FloorNormal);
float tangentVelMag = tangentVelocity.Length();
- if (tangentVelMag < TANGENT_VELOCITY_THRESHOLD)
+ if (tangentVelMag < DefaultRollout.TangentVelocityThreshold)
{
float effectiveRollingFriction = parameters.RollingFriction * spinMultiplier;
PhysicsLogger.Verbose($" ROLLING: vel={velocity.Length():F2} m/s, spin={omega.Length() / ShotSetup.RAD_PER_RPM:F0} rpm, c_rr={effectiveRollingFriction:F3} (×{spinMultiplier:F2})");
@@ -185,9 +111,9 @@ public Vector3 CalculateGroundForces(
{
float velocityMag = velocity.Length();
float baseFriction;
- if (velocityMag < FRICTION_BLEND_SPEED)
+ if (velocityMag < DefaultRollout.FrictionBlendSpeed)
{
- float blendFactor = Mathf.Clamp(velocityMag / FRICTION_BLEND_SPEED, 0.0f, 1.0f);
+ float blendFactor = Mathf.Clamp(velocityMag / DefaultRollout.FrictionBlendSpeed, 0.0f, 1.0f);
blendFactor = blendFactor * blendFactor;
baseFriction = Mathf.Lerp(parameters.RollingFriction, parameters.KineticFriction, blendFactor);
}
@@ -203,51 +129,11 @@ public Vector3 CalculateGroundForces(
return grassDrag + friction;
}
- ///
- /// Shared friction force calculation used by both ground forces and ground torques.
- /// Computes contact-point friction (rolling or slipping) with spin-based multiplier.
- ///
private Vector3 CalculateFrictionForce(
Vector3 velocity,
Vector3 omega,
PhysicsParams parameters)
- {
- Vector3 contactVelocity = velocity + omega.Cross(-parameters.FloorNormal * RADIUS);
- Vector3 tangentVelocity = contactVelocity - parameters.FloorNormal * contactVelocity.Dot(parameters.FloorNormal);
-
- float spinMultiplier = GetSpinFrictionMultiplier(omega, parameters.RolloutImpactSpin, velocity.Length());
- float tangentVelMag = tangentVelocity.Length();
-
- if (tangentVelMag < TANGENT_VELOCITY_THRESHOLD)
- {
- // Pure rolling
- Vector3 flatVelocity = velocity - parameters.FloorNormal * velocity.Dot(parameters.FloorNormal);
- Vector3 frictionDir = flatVelocity.Length() > 0.01f ? flatVelocity.Normalized() : Vector3.Zero;
- float effectiveRollingFriction = parameters.RollingFriction * spinMultiplier;
- return frictionDir * (-effectiveRollingFriction * MASS * 9.81f);
- }
- else
- {
- // Slipping — blended friction for smooth transition
- float velocityMag = velocity.Length();
- float baseFriction;
-
- if (velocityMag < FRICTION_BLEND_SPEED)
- {
- float blendFactor = Mathf.Clamp(velocityMag / FRICTION_BLEND_SPEED, 0.0f, 1.0f);
- blendFactor = blendFactor * blendFactor;
- baseFriction = Mathf.Lerp(parameters.RollingFriction, parameters.KineticFriction, blendFactor);
- }
- else
- {
- baseFriction = parameters.KineticFriction;
- }
-
- float effectiveFriction = baseFriction * spinMultiplier;
- Vector3 slipDir = tangentVelMag > 0.01f ? tangentVelocity.Normalized() : Vector3.Zero;
- return slipDir * (-effectiveFriction * MASS * 9.81f);
- }
- }
+ => CalculateFrictionForce(velocity, omega, parameters, DefaultRollout);
///
/// Calculate aerodynamic drag and Magnus forces
@@ -353,38 +239,12 @@ internal static FlightAerodynamicsSample SampleFlightAerodynamics(
);
}
- ///
- /// Greens can exhibit stronger check/spinback on steep, high-spin impacts.
- /// Model this as an effective increase in critical angle for high-spin wedge/flop
- /// impacts, while leaving lower-spin/low-speed impacts unchanged.
- ///
private static float GetEffectiveCriticalAngle(
PhysicsParams parameters,
float currentSpinRpm,
float impactSpeed,
PhysicsEnums.BallState currentState)
- {
- if (currentState != PhysicsEnums.BallState.Flight ||
- parameters.SpinbackThetaBoostMax <= 0.0f)
- {
- return parameters.CriticalAngle;
- }
-
- float spinRange = parameters.SpinbackSpinEndRpm - parameters.SpinbackSpinStartRpm;
- float spinT = spinRange > 0.0f
- ? Mathf.Clamp((currentSpinRpm - parameters.SpinbackSpinStartRpm) / spinRange, 0.0f, 1.0f)
- : 0.0f;
- spinT = spinT * spinT * (3.0f - 2.0f * spinT);
-
- float speedRange = parameters.SpinbackSpeedEndMps - parameters.SpinbackSpeedStartMps;
- float speedT = speedRange > 0.0f
- ? Mathf.Clamp((impactSpeed - parameters.SpinbackSpeedStartMps) / speedRange, 0.0f, 1.0f)
- : 0.0f;
- speedT = speedT * speedT * (3.0f - 2.0f * speedT);
-
- float boost = parameters.SpinbackThetaBoostMax * spinT * speedT;
- return parameters.CriticalAngle + boost;
- }
+ => BounceCalculator.GetEffectiveCriticalAngle(parameters, currentSpinRpm, impactSpeed, currentState);
///
/// Calculate total torques acting on the ball
@@ -445,303 +305,19 @@ public Vector3 CalculateGroundTorques(
return frictionTorque + grassTorque;
}
- ///
- /// Calculate bounce physics when ball impacts surface
- ///
public BounceResult CalculateBounce(
Vector3 vel,
Vector3 omega,
Vector3 normal,
PhysicsEnums.BallState currentState,
PhysicsParams parameters)
- {
- PhysicsEnums.BallState newState = currentState == PhysicsEnums.BallState.Flight
- ? PhysicsEnums.BallState.Rollout
- : currentState;
-
- // Decompose velocity
- Vector3 velNormal = vel.Project(normal);
- float speedNormal = velNormal.Length();
- Vector3 velTangent = vel - velNormal;
- float speedTangent = velTangent.Length();
-
- // Decompose angular velocity
- Vector3 omegaNormal = omega.Project(normal);
- Vector3 omegaTangent = omega - omegaNormal;
-
- // Calculate impact angle from the SURFACE (not from the normal)
- // vel.AngleTo(normal) gives angle to normal, but Penner's critical angle is from surface
- float angleToNormal = vel.AngleTo(normal);
- float impactAngle = Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f);
-
- // Use tangential spin magnitude for bounce calculation (backspin creates reverse velocity)
- float omegaTangentMagnitude = omegaTangent.Length();
-
- // Tangential retention based on spin
- float currentSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
-
- float tangentialRetention;
-
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- // First bounce from flight: Use spin-based penalty
- float spinFactor = Mathf.Clamp(1.0f - (currentSpinRpm / 8000.0f), 0.40f, 1.0f);
- tangentialRetention = 0.55f * spinFactor;
- }
- else
- {
- // Rollout bounces: Higher retention, no spin penalty
- // Use spin ratio to determine how much velocity to keep
- float ballSpeed = vel.Length();
- float spinRatio = ballSpeed > 0.1f ? (omega.Length() * RADIUS) / ballSpeed : 0.0f;
-
- // Low spin ratio = more rollout retention
- if (spinRatio < 0.20f)
- {
- tangentialRetention = Mathf.Lerp(0.85f, 0.70f, spinRatio / 0.20f);
- }
- else
- {
- tangentialRetention = 0.70f;
- }
- }
-
- if (newState == PhysicsEnums.BallState.Rollout)
- {
- PhysicsLogger.Verbose($" Bounce: spin={currentSpinRpm:F0} rpm, retention={tangentialRetention:F3}");
- }
-
- // Calculate new tangential speed
- float newTangentSpeed;
-
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- // First bounce from flight
- // The Penner model only works for HIGH-ENERGY steep impacts (full wedge shots)
- // For low-energy impacts (chip shots), use simple retention even if angle is steep
- // For shallow-angle impacts (driver shots), use simple retention
- float impactSpeed = vel.Length();
- bool hasSpinbackSurface = parameters.SpinbackThetaBoostMax > 0.0f || parameters.SpinbackResponseScale > 1.0f;
- float effectiveCriticalAngle = GetEffectiveCriticalAngle(parameters, currentSpinRpm, impactSpeed, currentState);
- float impactAngleDeg = Mathf.RadToDeg(impactAngle);
- float criticalAngleDeg = Mathf.RadToDeg(effectiveCriticalAngle);
- bool isSteepImpact = impactAngle >= effectiveCriticalAngle;
-
- // Surfaces without spinback keep the low-energy guard to prevent unrealistic
- // chip spin-back. Spinback surfaces allow steep-impact Penner behavior even
- // below the threshold so high-spin flop/wedge shots can naturally check/spin back.
- // High backspin (>4000 RPM) indicates a wedge/flop, not a chip — lower the guard.
- float pennerSpeedThreshold = 20.0f;
- if (currentSpinRpm > 4000.0f)
- {
- float spinT = Mathf.Clamp((currentSpinRpm - 4000.0f) / 4000.0f, 0.0f, 1.0f);
- pennerSpeedThreshold = Mathf.Lerp(20.0f, 12.0f, spinT);
- }
- bool shouldUsePenner = isSteepImpact && (impactSpeed >= pennerSpeedThreshold || hasSpinbackSurface);
-
- if (!shouldUsePenner)
- {
- // Shallow angle OR low energy (chip shots): use simple retention
- // This prevents chip shots from rolling backward even with high spin
- newTangentSpeed = speedTangent * tangentialRetention;
- if (!isSteepImpact)
- {
- PhysicsLogger.Verbose($" Bounce: Shallow angle ({impactAngleDeg:F2}° < {criticalAngleDeg:F2}°) - using simple retention");
- }
- else if (impactSpeed < pennerSpeedThreshold && !hasSpinbackSurface)
- {
- PhysicsLogger.Verbose($" Bounce: Low energy ({impactSpeed:F2} m/s < {pennerSpeedThreshold:F2} m/s) - using simple retention");
- }
- else
- {
- PhysicsLogger.Verbose($" Bounce: Using simple retention (surface={parameters.SurfaceType}, speed={impactSpeed:F2} m/s)");
- }
- PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, newTangentSpeed={newTangentSpeed:F2} m/s");
- }
- else
- {
- // Penner tangential model for steep impacts:
- // backspin term can reverse tangential velocity (spin-back) when large enough.
- // Surface response scales how strongly a lie converts spin into reverse tangential motion.
- float spinbackTerm = 2.0f * RADIUS * omegaTangentMagnitude * Mathf.Max(parameters.SpinbackResponseScale, 0.0f) / 7.0f;
- newTangentSpeed = tangentialRetention * vel.Length() * Mathf.Sin(impactAngle - effectiveCriticalAngle) -
- spinbackTerm;
- PhysicsLogger.Verbose($" Bounce: Penner model ({parameters.SurfaceType}) speed={impactSpeed:F2} m/s angle={impactAngleDeg:F2}° crit={criticalAngleDeg:F2}°");
- PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, spinbackScale={parameters.SpinbackResponseScale:F2}, newTangentSpeed={newTangentSpeed:F2} m/s");
- }
- }
- else
- {
- // Subsequent bounces during rollout: Simple friction factor (like libgolf)
- // Don't subtract spin - just apply friction to existing tangential velocity
- newTangentSpeed = speedTangent * tangentialRetention;
- }
-
- if (speedTangent < 0.01f && Mathf.Abs(newTangentSpeed) < 0.01f)
- {
- velTangent = Vector3.Zero;
- }
- else if (newTangentSpeed < 0.0f)
- {
- // Spin-back: reverse tangential direction
- velTangent = -velTangent.Normalized() * Mathf.Abs(newTangentSpeed);
- }
- else
- {
- velTangent = velTangent.LimitLength(newTangentSpeed);
- }
-
- // Update tangential angular velocity
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- // First bounce: compute omega from tangent speed
- float newOmegaTangent = Mathf.Abs(newTangentSpeed) / RADIUS;
- if (omegaTangent.Length() < 0.1f || newOmegaTangent < 0.01f)
- {
- omegaTangent = Vector3.Zero;
- }
- else if (newTangentSpeed < 0.0f)
- {
- omegaTangent = -omegaTangent.Normalized() * newOmegaTangent;
- }
- else
- {
- omegaTangent = omegaTangent.LimitLength(newOmegaTangent);
- }
- }
- else
- {
- // Rollout: preserve existing spin, don't force it to match rolling velocity
- // The ball will slip initially, but forcing high spin kills rollout energy
- // Natural spin decay will occur through ground torques
- if (newTangentSpeed > 0.05f)
- {
- // Keep existing spin magnitude but ensure it's in the right direction
- float existingSpinMag = omegaTangent.Length();
- Vector3 tangentDir = velTangent.Length() > 0.01f ? velTangent.Normalized() : Vector3.Right;
- Vector3 rollingAxis = normal.Cross(tangentDir).Normalized();
-
- // Gradually adjust spin toward rolling direction, but don't increase magnitude
- if (existingSpinMag > 0.05f)
- {
- omegaTangent = rollingAxis * existingSpinMag;
- }
- else
- {
- omegaTangent = Vector3.Zero;
- }
- }
- else
- {
- omegaTangent = Vector3.Zero;
- }
- }
+ => _bounceCalc.CalculateBounce(vel, omega, normal, currentState, parameters);
- // Coefficient of restitution (speed-dependent and spin-dependent)
- float cor;
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- // First bounce from flight: use base COR, reduced by spin
- // High spin causes ball to "stick" to turf, reducing bounce
- float baseCor = GetCoefficientOfRestitution(speedNormal);
-
- // Spin-based COR reduction
- float spinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
-
- // Velocity scaling: High-spin COR reduction should only apply to high-energy impacts
- // The "bite" effect from spin depends on impact energy, not just spin rate
- float corVelocityScale;
- if (speedNormal < 12.0f)
- {
- // Low-speed impacts (chip shots): Reduced COR penalty
- corVelocityScale = Mathf.Lerp(0.0f, 0.50f, speedNormal / 12.0f);
- }
- else if (speedNormal < 25.0f)
- {
- // Medium-speed impacts: Transition
- corVelocityScale = Mathf.Lerp(0.50f, 1.0f, (speedNormal - 12.0f) / 13.0f);
- }
- else
- {
- // High-speed impacts: Full penalty
- corVelocityScale = 1.0f;
- }
-
- float spinCORReduction;
-
- if (spinRpm < 1500.0f)
- {
- // Low spin: Minimal COR reduction (0% to 30%)
- spinCORReduction = (spinRpm / 1500.0f) * 0.30f;
- }
- else
- {
- // High spin: Strong COR reduction (30% to 70%)
- // At 1500 rpm: 30% reduction
- // At 3000+ rpm: 70% reduction (flop shots stick!)
- float excessSpin = spinRpm - 1500.0f;
- float spinFactor = Mathf.Min(excessSpin / 1500.0f, 1.0f);
- float maxReduction = 0.30f + spinFactor * 0.40f;
- spinCORReduction = maxReduction * corVelocityScale;
- }
-
- cor = baseCor * (1.0f - spinCORReduction);
-
- // Debug output for first bounce
- if (newState == PhysicsEnums.BallState.Rollout)
- {
- PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, spin={spinRpm:F0} rpm");
- PhysicsLogger.Verbose($" baseCOR={baseCor:F3}, spinReduction={spinCORReduction:F2}, finalCOR={cor:F3}");
- PhysicsLogger.Verbose($" velNormal will be {speedNormal * cor:F2} m/s");
- }
- }
- else
- {
- // Rollout bounces: kill small bounces aggressively to settle into roll
- if (speedNormal < 4.0f)
- {
- cor = 0.0f; // Kill small rollout bounces completely
- }
- else
- {
- cor = GetCoefficientOfRestitution(speedNormal) * 0.5f; // Halve COR for rollout
- }
-
- if (speedNormal > 0.5f)
- {
- PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, COR={cor:F3}, velNormal will be {speedNormal * cor:F2} m/s");
- }
- }
-
- velNormal = velNormal * -cor;
-
- Vector3 newOmega = omegaNormal + omegaTangent;
- Vector3 newVelocity = velNormal + velTangent;
-
- return new BounceResult(newVelocity, newOmega, newState);
- }
-
- ///
- /// Get coefficient of restitution based on impact speed
- ///
public float GetCoefficientOfRestitution(float speedNormal)
- {
- return GetCoefficientOfRestitution(speedNormal, BounceProfile.Default);
- }
+ => _bounceCalc.GetCoefficientOfRestitution(speedNormal);
public float GetCoefficientOfRestitution(float speedNormal, BounceProfile bp)
- {
- if (speedNormal > bp.CorHighSpeedThreshold)
- return bp.CorHighSpeedCap;
- else if (speedNormal < bp.CorKillThreshold)
- return 0.0f;
- else
- {
- return bp.CorBaseA + bp.CorBaseB * speedNormal + bp.CorBaseC * speedNormal * speedNormal;
- }
- }
-
- // ── Profile-aware overloads ──
+ => _bounceCalc.GetCoefficientOfRestitution(speedNormal, bp);
public BounceResult CalculateBounce(
Vector3 vel,
@@ -750,208 +326,7 @@ public BounceResult CalculateBounce(
PhysicsEnums.BallState currentState,
PhysicsParams parameters,
BounceProfile bp)
- {
- PhysicsEnums.BallState newState = currentState == PhysicsEnums.BallState.Flight
- ? PhysicsEnums.BallState.Rollout
- : currentState;
-
- Vector3 velNormal = vel.Project(normal);
- float speedNormal = velNormal.Length();
- Vector3 velTangent = vel - velNormal;
- float speedTangent = velTangent.Length();
-
- Vector3 omegaNormal = omega.Project(normal);
- Vector3 omegaTangent = omega - omegaNormal;
-
- float angleToNormal = vel.AngleTo(normal);
- float impactAngle = Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f);
-
- float omegaTangentMagnitude = omegaTangent.Length();
- float currentSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
-
- float tangentialRetention;
-
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- float spinFactor = Mathf.Clamp(1.0f - (currentSpinRpm / bp.FlightSpinFactorDivisor), bp.FlightSpinFactorMin, 1.0f);
- tangentialRetention = bp.FlightTangentialRetentionBase * spinFactor;
- }
- else
- {
- float ballSpeed = vel.Length();
- float spinRatio = ballSpeed > 0.1f ? (omega.Length() * RADIUS) / ballSpeed : 0.0f;
-
- if (spinRatio < bp.RolloutSpinRatioThreshold)
- {
- tangentialRetention = Mathf.Lerp(bp.RolloutLowSpinRetention, bp.RolloutHighSpinRetention, spinRatio / bp.RolloutSpinRatioThreshold);
- }
- else
- {
- tangentialRetention = bp.RolloutHighSpinRetention;
- }
- }
-
- if (newState == PhysicsEnums.BallState.Rollout)
- {
- PhysicsLogger.Verbose($" Bounce: spin={currentSpinRpm:F0} rpm, retention={tangentialRetention:F3}");
- }
-
- float newTangentSpeed;
-
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- float impactSpeed = vel.Length();
- bool hasSpinbackSurface = parameters.SpinbackThetaBoostMax > 0.0f || parameters.SpinbackResponseScale > 1.0f;
- float effectiveCriticalAngle = GetEffectiveCriticalAngle(parameters, currentSpinRpm, impactSpeed, currentState);
- float impactAngleDeg = Mathf.RadToDeg(impactAngle);
- float criticalAngleDeg = Mathf.RadToDeg(effectiveCriticalAngle);
- bool isSteepImpact = impactAngle >= effectiveCriticalAngle;
-
- bool shouldUsePenner = isSteepImpact && (impactSpeed >= bp.PennerLowEnergyThreshold || hasSpinbackSurface);
-
- if (!shouldUsePenner)
- {
- newTangentSpeed = speedTangent * tangentialRetention;
- if (!isSteepImpact)
- PhysicsLogger.Verbose($" Bounce: Shallow angle ({impactAngleDeg:F2}° < {criticalAngleDeg:F2}°) - using simple retention");
- else if (impactSpeed < bp.PennerLowEnergyThreshold && !hasSpinbackSurface)
- PhysicsLogger.Verbose($" Bounce: Low energy ({impactSpeed:F2} m/s < {bp.PennerLowEnergyThreshold:F1} m/s) - using simple retention");
- else
- PhysicsLogger.Verbose($" Bounce: Using simple retention (surface={parameters.SurfaceType}, speed={impactSpeed:F2} m/s)");
- PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, newTangentSpeed={newTangentSpeed:F2} m/s");
- }
- else
- {
- float spinbackTerm = 2.0f * RADIUS * omegaTangentMagnitude * Mathf.Max(parameters.SpinbackResponseScale, 0.0f) / 7.0f;
- newTangentSpeed = tangentialRetention * vel.Length() * Mathf.Sin(impactAngle - effectiveCriticalAngle) -
- spinbackTerm;
- PhysicsLogger.Verbose($" Bounce: Penner model ({parameters.SurfaceType}) speed={impactSpeed:F2} m/s angle={impactAngleDeg:F2}° crit={criticalAngleDeg:F2}°");
- PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, spinbackScale={parameters.SpinbackResponseScale:F2}, newTangentSpeed={newTangentSpeed:F2} m/s");
- }
- }
- else
- {
- newTangentSpeed = speedTangent * tangentialRetention;
- }
-
- if (speedTangent < 0.01f && Mathf.Abs(newTangentSpeed) < 0.01f)
- {
- velTangent = Vector3.Zero;
- }
- else if (newTangentSpeed < 0.0f)
- {
- velTangent = -velTangent.Normalized() * Mathf.Abs(newTangentSpeed);
- }
- else
- {
- velTangent = velTangent.LimitLength(newTangentSpeed);
- }
-
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- float newOmegaTangent = Mathf.Abs(newTangentSpeed) / RADIUS;
- if (omegaTangent.Length() < 0.1f || newOmegaTangent < 0.01f)
- {
- omegaTangent = Vector3.Zero;
- }
- else if (newTangentSpeed < 0.0f)
- {
- omegaTangent = -omegaTangent.Normalized() * newOmegaTangent;
- }
- else
- {
- omegaTangent = omegaTangent.LimitLength(newOmegaTangent);
- }
- }
- else
- {
- if (newTangentSpeed > 0.05f)
- {
- float existingSpinMag = omegaTangent.Length();
- Vector3 tangentDir = velTangent.Length() > 0.01f ? velTangent.Normalized() : Vector3.Right;
- Vector3 rollingAxis = normal.Cross(tangentDir).Normalized();
-
- if (existingSpinMag > 0.05f)
- {
- omegaTangent = rollingAxis * existingSpinMag;
- }
- else
- {
- omegaTangent = Vector3.Zero;
- }
- }
- else
- {
- omegaTangent = Vector3.Zero;
- }
- }
-
- float cor;
- if (currentState == PhysicsEnums.BallState.Flight)
- {
- float baseCor = GetCoefficientOfRestitution(speedNormal, bp);
- float spinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
-
- float corVelocityScale;
- if (speedNormal < bp.CorVelocityLowThreshold)
- {
- corVelocityScale = Mathf.Lerp(0.0f, bp.CorVelocityLowScale, speedNormal / bp.CorVelocityLowThreshold);
- }
- else if (speedNormal < bp.CorVelocityMidThreshold)
- {
- corVelocityScale = Mathf.Lerp(bp.CorVelocityLowScale, 1.0f, (speedNormal - bp.CorVelocityLowThreshold) / (bp.CorVelocityMidThreshold - bp.CorVelocityLowThreshold));
- }
- else
- {
- corVelocityScale = 1.0f;
- }
-
- float spinCORReduction;
- if (spinRpm < bp.SpinCorLowSpinThreshold)
- {
- spinCORReduction = (spinRpm / bp.SpinCorLowSpinThreshold) * bp.SpinCorLowSpinMaxReduction;
- }
- else
- {
- float excessSpin = spinRpm - bp.SpinCorLowSpinThreshold;
- float spinFactor = Mathf.Min(excessSpin / bp.SpinCorHighSpinRangeRpm, 1.0f);
- float maxReduction = bp.SpinCorLowSpinMaxReduction + spinFactor * bp.SpinCorHighSpinAdditionalReduction;
- spinCORReduction = maxReduction * corVelocityScale;
- }
-
- cor = baseCor * (1.0f - spinCORReduction);
-
- if (newState == PhysicsEnums.BallState.Rollout)
- {
- PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, spin={spinRpm:F0} rpm");
- PhysicsLogger.Verbose($" baseCOR={baseCor:F3}, spinReduction={spinCORReduction:F2}, finalCOR={cor:F3}");
- PhysicsLogger.Verbose($" velNormal will be {speedNormal * cor:F2} m/s");
- }
- }
- else
- {
- if (speedNormal < bp.RolloutBounceCorKillThreshold)
- {
- cor = 0.0f;
- }
- else
- {
- cor = GetCoefficientOfRestitution(speedNormal, bp) * bp.RolloutBounceCorScale;
- }
-
- if (speedNormal > 0.5f)
- {
- PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, COR={cor:F3}, velNormal will be {speedNormal * cor:F2} m/s");
- }
- }
-
- velNormal = velNormal * -cor;
-
- Vector3 newOmega = omegaNormal + omegaTangent;
- Vector3 newVelocity = velNormal + velTangent;
-
- return new BounceResult(newVelocity, newOmega, newState);
- }
+ => _bounceCalc.CalculateBounce(vel, omega, normal, currentState, parameters, bp);
private float GetSpinFrictionMultiplier(Vector3 omega, float impactSpinRpm, float ballSpeed, RolloutProfile rp)
{
@@ -1010,7 +385,7 @@ private Vector3 CalculateFrictionForce(
float spinMultiplier = GetSpinFrictionMultiplier(omega, parameters.RolloutImpactSpin, velocity.Length(), rp);
float tangentVelMag = tangentVelocity.Length();
- if (tangentVelMag < TANGENT_VELOCITY_THRESHOLD)
+ if (tangentVelMag < rp.TangentVelocityThreshold)
{
Vector3 flatVelocity = velocity - parameters.FloorNormal * velocity.Dot(parameters.FloorNormal);
Vector3 frictionDir = flatVelocity.Length() > 0.01f ? flatVelocity.Normalized() : Vector3.Zero;
diff --git a/addons/openfairway/physics/BounceCalculator.cs b/addons/openfairway/physics/BounceCalculator.cs
new file mode 100644
index 0000000..4a76bff
--- /dev/null
+++ b/addons/openfairway/physics/BounceCalculator.cs
@@ -0,0 +1,550 @@
+using Godot;
+
+///
+/// Bounce physics calculations extracted from BallPhysics.
+/// Handles impact bounce resolution, coefficient of restitution, and critical angle computation.
+///
+[GlobalClass]
+public partial class BounceCalculator : RefCounted
+{
+ ///
+ /// Calculate bounce physics when ball impacts surface
+ ///
+ public BounceResult CalculateBounce(
+ Vector3 vel,
+ Vector3 omega,
+ Vector3 normal,
+ PhysicsEnums.BallState currentState,
+ PhysicsParams parameters)
+ {
+ PhysicsEnums.BallState newState = currentState == PhysicsEnums.BallState.Flight
+ ? PhysicsEnums.BallState.Rollout
+ : currentState;
+
+ // Decompose velocity
+ Vector3 velNormal = vel.Project(normal);
+ float speedNormal = velNormal.Length();
+ Vector3 velTangent = vel - velNormal;
+ float speedTangent = velTangent.Length();
+
+ // Decompose angular velocity
+ Vector3 omegaNormal = omega.Project(normal);
+ Vector3 omegaTangent = omega - omegaNormal;
+
+ // Calculate impact angle from the SURFACE (not from the normal)
+ // vel.AngleTo(normal) gives angle to normal, but Penner's critical angle is from surface
+ float angleToNormal = vel.AngleTo(normal);
+ float impactAngle = Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f);
+
+ // Use tangential spin magnitude for bounce calculation (backspin creates reverse velocity)
+ float omegaTangentMagnitude = omegaTangent.Length();
+
+ // Tangential retention based on spin
+ float currentSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
+
+ float tangentialRetention;
+
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ // First bounce from flight: Use spin-based penalty
+ float spinFactor = Mathf.Clamp(1.0f - (currentSpinRpm / 8000.0f), 0.40f, 1.0f);
+ tangentialRetention = 0.55f * spinFactor;
+ }
+ else
+ {
+ // Rollout bounces: Higher retention, no spin penalty
+ // Use spin ratio to determine how much velocity to keep
+ float ballSpeed = vel.Length();
+ float spinRatio = ballSpeed > 0.1f ? (omega.Length() * BallPhysics.RADIUS) / ballSpeed : 0.0f;
+
+ // Low spin ratio = more rollout retention
+ if (spinRatio < 0.20f)
+ {
+ tangentialRetention = Mathf.Lerp(0.85f, 0.70f, spinRatio / 0.20f);
+ }
+ else
+ {
+ tangentialRetention = 0.70f;
+ }
+ }
+
+ if (newState == PhysicsEnums.BallState.Rollout)
+ {
+ PhysicsLogger.Verbose($" Bounce: spin={currentSpinRpm:F0} rpm, retention={tangentialRetention:F3}");
+ }
+
+ // Calculate new tangential speed
+ float newTangentSpeed;
+
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ // First bounce from flight
+ // The Penner model only works for HIGH-ENERGY steep impacts (full wedge shots)
+ // For low-energy impacts (chip shots), use simple retention even if angle is steep
+ // For shallow-angle impacts (driver shots), use simple retention
+ float impactSpeed = vel.Length();
+ bool hasSpinbackSurface = parameters.SpinbackThetaBoostMax > 0.0f || parameters.SpinbackResponseScale > 1.0f;
+ float effectiveCriticalAngle = GetEffectiveCriticalAngle(parameters, currentSpinRpm, impactSpeed, currentState);
+ float impactAngleDeg = Mathf.RadToDeg(impactAngle);
+ float criticalAngleDeg = Mathf.RadToDeg(effectiveCriticalAngle);
+ bool isSteepImpact = impactAngle >= effectiveCriticalAngle;
+
+ // Surfaces without spinback keep the low-energy guard to prevent unrealistic
+ // chip spin-back. Spinback surfaces allow steep-impact Penner behavior even
+ // below the threshold so high-spin flop/wedge shots can naturally check/spin back.
+ // High backspin (>4000 RPM) indicates a wedge/flop, not a chip — lower the guard.
+ float pennerSpeedThreshold = 20.0f;
+ if (currentSpinRpm > 4000.0f)
+ {
+ float spinT = Mathf.Clamp((currentSpinRpm - 4000.0f) / 4000.0f, 0.0f, 1.0f);
+ pennerSpeedThreshold = Mathf.Lerp(20.0f, 12.0f, spinT);
+ }
+ bool shouldUsePenner = isSteepImpact && (impactSpeed >= pennerSpeedThreshold || hasSpinbackSurface);
+
+ if (!shouldUsePenner)
+ {
+ // Shallow angle OR low energy (chip shots): use simple retention
+ // This prevents chip shots from rolling backward even with high spin
+ newTangentSpeed = speedTangent * tangentialRetention;
+ if (!isSteepImpact)
+ {
+ PhysicsLogger.Verbose($" Bounce: Shallow angle ({impactAngleDeg:F2}° < {criticalAngleDeg:F2}°) - using simple retention");
+ }
+ else if (impactSpeed < pennerSpeedThreshold && !hasSpinbackSurface)
+ {
+ PhysicsLogger.Verbose($" Bounce: Low energy ({impactSpeed:F2} m/s < {pennerSpeedThreshold:F2} m/s) - using simple retention");
+ }
+ else
+ {
+ PhysicsLogger.Verbose($" Bounce: Using simple retention (surface={parameters.SurfaceType}, speed={impactSpeed:F2} m/s)");
+ }
+ PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, newTangentSpeed={newTangentSpeed:F2} m/s");
+ }
+ else
+ {
+ // Penner tangential model for steep impacts:
+ // backspin term can reverse tangential velocity (spin-back) when large enough.
+ // Surface response scales how strongly a lie converts spin into reverse tangential motion.
+ float spinbackTerm = 2.0f * BallPhysics.RADIUS * omegaTangentMagnitude * Mathf.Max(parameters.SpinbackResponseScale, 0.0f) / 7.0f;
+ newTangentSpeed = tangentialRetention * vel.Length() * Mathf.Sin(impactAngle - effectiveCriticalAngle) -
+ spinbackTerm;
+ PhysicsLogger.Verbose($" Bounce: Penner model ({parameters.SurfaceType}) speed={impactSpeed:F2} m/s angle={impactAngleDeg:F2}° crit={criticalAngleDeg:F2}°");
+ PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, spinbackScale={parameters.SpinbackResponseScale:F2}, newTangentSpeed={newTangentSpeed:F2} m/s");
+ }
+ }
+ else
+ {
+ // Subsequent bounces during rollout: Simple friction factor (like libgolf)
+ // Don't subtract spin - just apply friction to existing tangential velocity
+ newTangentSpeed = speedTangent * tangentialRetention;
+ }
+
+ if (speedTangent < 0.01f && Mathf.Abs(newTangentSpeed) < 0.01f)
+ {
+ velTangent = Vector3.Zero;
+ }
+ else if (newTangentSpeed < 0.0f)
+ {
+ // Spin-back: reverse tangential direction
+ velTangent = -velTangent.Normalized() * Mathf.Abs(newTangentSpeed);
+ }
+ else
+ {
+ velTangent = velTangent.LimitLength(newTangentSpeed);
+ }
+
+ // Update tangential angular velocity
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ // First bounce: compute omega from tangent speed
+ float newOmegaTangent = Mathf.Abs(newTangentSpeed) / BallPhysics.RADIUS;
+ if (omegaTangent.Length() < 0.1f || newOmegaTangent < 0.01f)
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ else if (newTangentSpeed < 0.0f)
+ {
+ omegaTangent = -omegaTangent.Normalized() * newOmegaTangent;
+ }
+ else
+ {
+ omegaTangent = omegaTangent.LimitLength(newOmegaTangent);
+ }
+ }
+ else
+ {
+ // Rollout: preserve existing spin, don't force it to match rolling velocity
+ // The ball will slip initially, but forcing high spin kills rollout energy
+ // Natural spin decay will occur through ground torques
+ if (newTangentSpeed > 0.05f)
+ {
+ // Keep existing spin magnitude but ensure it's in the right direction
+ float existingSpinMag = omegaTangent.Length();
+ Vector3 tangentDir = velTangent.Length() > 0.01f ? velTangent.Normalized() : Vector3.Right;
+ Vector3 rollingAxis = normal.Cross(tangentDir).Normalized();
+
+ // Gradually adjust spin toward rolling direction, but don't increase magnitude
+ if (existingSpinMag > 0.05f)
+ {
+ omegaTangent = rollingAxis * existingSpinMag;
+ }
+ else
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ }
+ else
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ }
+
+ // Coefficient of restitution (speed-dependent and spin-dependent)
+ float cor;
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ // First bounce from flight: use base COR, reduced by spin
+ // High spin causes ball to "stick" to turf, reducing bounce
+ float baseCor = GetCoefficientOfRestitution(speedNormal);
+
+ // Spin-based COR reduction
+ float spinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
+
+ // Velocity scaling: High-spin COR reduction should only apply to high-energy impacts
+ // The "bite" effect from spin depends on impact energy, not just spin rate
+ float corVelocityScale;
+ if (speedNormal < 12.0f)
+ {
+ // Low-speed impacts (chip shots): Reduced COR penalty
+ corVelocityScale = Mathf.Lerp(0.0f, 0.50f, speedNormal / 12.0f);
+ }
+ else if (speedNormal < 25.0f)
+ {
+ // Medium-speed impacts: Transition
+ corVelocityScale = Mathf.Lerp(0.50f, 1.0f, (speedNormal - 12.0f) / 13.0f);
+ }
+ else
+ {
+ // High-speed impacts: Full penalty
+ corVelocityScale = 1.0f;
+ }
+
+ float spinCORReduction;
+
+ if (spinRpm < 1500.0f)
+ {
+ // Low spin: Minimal COR reduction (0% to 30%)
+ spinCORReduction = (spinRpm / 1500.0f) * 0.30f;
+ }
+ else
+ {
+ // High spin: Strong COR reduction (30% to 70%)
+ // At 1500 rpm: 30% reduction
+ // At 3000+ rpm: 70% reduction (flop shots stick!)
+ float excessSpin = spinRpm - 1500.0f;
+ float spinFactor = Mathf.Min(excessSpin / 1500.0f, 1.0f);
+ float maxReduction = 0.30f + spinFactor * 0.40f;
+ spinCORReduction = maxReduction * corVelocityScale;
+ }
+
+ cor = baseCor * (1.0f - spinCORReduction);
+
+ // Debug output for first bounce
+ if (newState == PhysicsEnums.BallState.Rollout)
+ {
+ PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, spin={spinRpm:F0} rpm");
+ PhysicsLogger.Verbose($" baseCOR={baseCor:F3}, spinReduction={spinCORReduction:F2}, finalCOR={cor:F3}");
+ PhysicsLogger.Verbose($" velNormal will be {speedNormal * cor:F2} m/s");
+ }
+ }
+ else
+ {
+ // Rollout bounces: kill small bounces aggressively to settle into roll
+ if (speedNormal < 4.0f)
+ {
+ cor = 0.0f; // Kill small rollout bounces completely
+ }
+ else
+ {
+ cor = GetCoefficientOfRestitution(speedNormal) * 0.5f; // Halve COR for rollout
+ }
+
+ if (speedNormal > 0.5f)
+ {
+ PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, COR={cor:F3}, velNormal will be {speedNormal * cor:F2} m/s");
+ }
+ }
+
+ velNormal = velNormal * -cor;
+
+ Vector3 newOmega = omegaNormal + omegaTangent;
+ Vector3 newVelocity = velNormal + velTangent;
+
+ return new BounceResult(newVelocity, newOmega, newState);
+ }
+
+ ///
+ /// Get coefficient of restitution based on impact speed
+ ///
+ public float GetCoefficientOfRestitution(float speedNormal)
+ {
+ return GetCoefficientOfRestitution(speedNormal, BounceProfile.Default);
+ }
+
+ public float GetCoefficientOfRestitution(float speedNormal, BounceProfile bp)
+ {
+ if (speedNormal > bp.CorHighSpeedThreshold)
+ return bp.CorHighSpeedCap;
+ else if (speedNormal < bp.CorKillThreshold)
+ return 0.0f;
+ else
+ {
+ return bp.CorBaseA + bp.CorBaseB * speedNormal + bp.CorBaseC * speedNormal * speedNormal;
+ }
+ }
+
+ // ── Profile-aware overload ──
+
+ public BounceResult CalculateBounce(
+ Vector3 vel,
+ Vector3 omega,
+ Vector3 normal,
+ PhysicsEnums.BallState currentState,
+ PhysicsParams parameters,
+ BounceProfile bp)
+ {
+ PhysicsEnums.BallState newState = currentState == PhysicsEnums.BallState.Flight
+ ? PhysicsEnums.BallState.Rollout
+ : currentState;
+
+ Vector3 velNormal = vel.Project(normal);
+ float speedNormal = velNormal.Length();
+ Vector3 velTangent = vel - velNormal;
+ float speedTangent = velTangent.Length();
+
+ Vector3 omegaNormal = omega.Project(normal);
+ Vector3 omegaTangent = omega - omegaNormal;
+
+ float angleToNormal = vel.AngleTo(normal);
+ float impactAngle = Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f);
+
+ float omegaTangentMagnitude = omegaTangent.Length();
+ float currentSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
+
+ float tangentialRetention;
+
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ float spinFactor = Mathf.Clamp(1.0f - (currentSpinRpm / bp.FlightSpinFactorDivisor), bp.FlightSpinFactorMin, 1.0f);
+ tangentialRetention = bp.FlightTangentialRetentionBase * spinFactor;
+ }
+ else
+ {
+ float ballSpeed = vel.Length();
+ float spinRatio = ballSpeed > 0.1f ? (omega.Length() * BallPhysics.RADIUS) / ballSpeed : 0.0f;
+
+ if (spinRatio < bp.RolloutSpinRatioThreshold)
+ {
+ tangentialRetention = Mathf.Lerp(bp.RolloutLowSpinRetention, bp.RolloutHighSpinRetention, spinRatio / bp.RolloutSpinRatioThreshold);
+ }
+ else
+ {
+ tangentialRetention = bp.RolloutHighSpinRetention;
+ }
+ }
+
+ if (newState == PhysicsEnums.BallState.Rollout)
+ {
+ PhysicsLogger.Verbose($" Bounce: spin={currentSpinRpm:F0} rpm, retention={tangentialRetention:F3}");
+ }
+
+ float newTangentSpeed;
+
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ float impactSpeed = vel.Length();
+ bool hasSpinbackSurface = parameters.SpinbackThetaBoostMax > 0.0f || parameters.SpinbackResponseScale > 1.0f;
+ float effectiveCriticalAngle = GetEffectiveCriticalAngle(parameters, currentSpinRpm, impactSpeed, currentState);
+ float impactAngleDeg = Mathf.RadToDeg(impactAngle);
+ float criticalAngleDeg = Mathf.RadToDeg(effectiveCriticalAngle);
+ bool isSteepImpact = impactAngle >= effectiveCriticalAngle;
+
+ bool shouldUsePenner = isSteepImpact && (impactSpeed >= bp.PennerLowEnergyThreshold || hasSpinbackSurface);
+
+ if (!shouldUsePenner)
+ {
+ newTangentSpeed = speedTangent * tangentialRetention;
+ if (!isSteepImpact)
+ PhysicsLogger.Verbose($" Bounce: Shallow angle ({impactAngleDeg:F2}° < {criticalAngleDeg:F2}°) - using simple retention");
+ else if (impactSpeed < bp.PennerLowEnergyThreshold && !hasSpinbackSurface)
+ PhysicsLogger.Verbose($" Bounce: Low energy ({impactSpeed:F2} m/s < {bp.PennerLowEnergyThreshold:F1} m/s) - using simple retention");
+ else
+ PhysicsLogger.Verbose($" Bounce: Using simple retention (surface={parameters.SurfaceType}, speed={impactSpeed:F2} m/s)");
+ PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, newTangentSpeed={newTangentSpeed:F2} m/s");
+ }
+ else
+ {
+ float spinbackTerm = 2.0f * BallPhysics.RADIUS * omegaTangentMagnitude * Mathf.Max(parameters.SpinbackResponseScale, 0.0f) / 7.0f;
+ newTangentSpeed = tangentialRetention * vel.Length() * Mathf.Sin(impactAngle - effectiveCriticalAngle) -
+ spinbackTerm;
+ PhysicsLogger.Verbose($" Bounce: Penner model ({parameters.SurfaceType}) speed={impactSpeed:F2} m/s angle={impactAngleDeg:F2}° crit={criticalAngleDeg:F2}°");
+ PhysicsLogger.Verbose($" speedTangent={speedTangent:F2} m/s, spinbackScale={parameters.SpinbackResponseScale:F2}, newTangentSpeed={newTangentSpeed:F2} m/s");
+ }
+ }
+ else
+ {
+ newTangentSpeed = speedTangent * tangentialRetention;
+ }
+
+ if (speedTangent < 0.01f && Mathf.Abs(newTangentSpeed) < 0.01f)
+ {
+ velTangent = Vector3.Zero;
+ }
+ else if (newTangentSpeed < 0.0f)
+ {
+ velTangent = -velTangent.Normalized() * Mathf.Abs(newTangentSpeed);
+ }
+ else
+ {
+ velTangent = velTangent.LimitLength(newTangentSpeed);
+ }
+
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ float newOmegaTangent = Mathf.Abs(newTangentSpeed) / BallPhysics.RADIUS;
+ if (omegaTangent.Length() < 0.1f || newOmegaTangent < 0.01f)
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ else if (newTangentSpeed < 0.0f)
+ {
+ omegaTangent = -omegaTangent.Normalized() * newOmegaTangent;
+ }
+ else
+ {
+ omegaTangent = omegaTangent.LimitLength(newOmegaTangent);
+ }
+ }
+ else
+ {
+ if (newTangentSpeed > 0.05f)
+ {
+ float existingSpinMag = omegaTangent.Length();
+ Vector3 tangentDir = velTangent.Length() > 0.01f ? velTangent.Normalized() : Vector3.Right;
+ Vector3 rollingAxis = normal.Cross(tangentDir).Normalized();
+
+ if (existingSpinMag > 0.05f)
+ {
+ omegaTangent = rollingAxis * existingSpinMag;
+ }
+ else
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ }
+ else
+ {
+ omegaTangent = Vector3.Zero;
+ }
+ }
+
+ float cor;
+ if (currentState == PhysicsEnums.BallState.Flight)
+ {
+ float baseCor = GetCoefficientOfRestitution(speedNormal, bp);
+ float spinRpm = omega.Length() / ShotSetup.RAD_PER_RPM;
+
+ float corVelocityScale;
+ if (speedNormal < bp.CorVelocityLowThreshold)
+ {
+ corVelocityScale = Mathf.Lerp(0.0f, bp.CorVelocityLowScale, speedNormal / bp.CorVelocityLowThreshold);
+ }
+ else if (speedNormal < bp.CorVelocityMidThreshold)
+ {
+ corVelocityScale = Mathf.Lerp(bp.CorVelocityLowScale, 1.0f, (speedNormal - bp.CorVelocityLowThreshold) / (bp.CorVelocityMidThreshold - bp.CorVelocityLowThreshold));
+ }
+ else
+ {
+ corVelocityScale = 1.0f;
+ }
+
+ float spinCORReduction;
+ if (spinRpm < bp.SpinCorLowSpinThreshold)
+ {
+ spinCORReduction = (spinRpm / bp.SpinCorLowSpinThreshold) * bp.SpinCorLowSpinMaxReduction;
+ }
+ else
+ {
+ float excessSpin = spinRpm - bp.SpinCorLowSpinThreshold;
+ float spinFactor = Mathf.Min(excessSpin / bp.SpinCorHighSpinRangeRpm, 1.0f);
+ float maxReduction = bp.SpinCorLowSpinMaxReduction + spinFactor * bp.SpinCorHighSpinAdditionalReduction;
+ spinCORReduction = maxReduction * corVelocityScale;
+ }
+
+ cor = baseCor * (1.0f - spinCORReduction);
+
+ if (newState == PhysicsEnums.BallState.Rollout)
+ {
+ PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, spin={spinRpm:F0} rpm");
+ PhysicsLogger.Verbose($" baseCOR={baseCor:F3}, spinReduction={spinCORReduction:F2}, finalCOR={cor:F3}");
+ PhysicsLogger.Verbose($" velNormal will be {speedNormal * cor:F2} m/s");
+ }
+ }
+ else
+ {
+ if (speedNormal < bp.RolloutBounceCorKillThreshold)
+ {
+ cor = 0.0f;
+ }
+ else
+ {
+ cor = GetCoefficientOfRestitution(speedNormal, bp) * bp.RolloutBounceCorScale;
+ }
+
+ if (speedNormal > 0.5f)
+ {
+ PhysicsLogger.Verbose($" speedNormal={speedNormal:F2} m/s, COR={cor:F3}, velNormal will be {speedNormal * cor:F2} m/s");
+ }
+ }
+
+ velNormal = velNormal * -cor;
+
+ Vector3 newOmega = omegaNormal + omegaTangent;
+ Vector3 newVelocity = velNormal + velTangent;
+
+ return new BounceResult(newVelocity, newOmega, newState);
+ }
+
+ ///
+ /// Greens can exhibit stronger check/spinback on steep, high-spin impacts.
+ /// Model this as an effective increase in critical angle for high-spin wedge/flop
+ /// impacts, while leaving lower-spin/low-speed impacts unchanged.
+ ///
+ internal static float GetEffectiveCriticalAngle(
+ PhysicsParams parameters,
+ float currentSpinRpm,
+ float impactSpeed,
+ PhysicsEnums.BallState currentState)
+ {
+ if (currentState != PhysicsEnums.BallState.Flight ||
+ parameters.SpinbackThetaBoostMax <= 0.0f)
+ {
+ return parameters.CriticalAngle;
+ }
+
+ float spinRange = parameters.SpinbackSpinEndRpm - parameters.SpinbackSpinStartRpm;
+ float spinT = spinRange > 0.0f
+ ? Mathf.Clamp((currentSpinRpm - parameters.SpinbackSpinStartRpm) / spinRange, 0.0f, 1.0f)
+ : 0.0f;
+ spinT = spinT * spinT * (3.0f - 2.0f * spinT);
+
+ float speedRange = parameters.SpinbackSpeedEndMps - parameters.SpinbackSpeedStartMps;
+ float speedT = speedRange > 0.0f
+ ? Mathf.Clamp((impactSpeed - parameters.SpinbackSpeedStartMps) / speedRange, 0.0f, 1.0f)
+ : 0.0f;
+ speedT = speedT * speedT * (3.0f - 2.0f * speedT);
+
+ float boost = parameters.SpinbackThetaBoostMax * spinT * speedT;
+ return parameters.CriticalAngle + boost;
+ }
+}
diff --git a/addons/openfairway/physics/BounceCalculator.cs.uid b/addons/openfairway/physics/BounceCalculator.cs.uid
new file mode 100644
index 0000000..23ab14f
--- /dev/null
+++ b/addons/openfairway/physics/BounceCalculator.cs.uid
@@ -0,0 +1 @@
+uid://ceyra2rahbv4l
diff --git a/addons/openfairway/physics/RolloutProfile.cs b/addons/openfairway/physics/RolloutProfile.cs
index 0569afd..6012b4e 100644
--- a/addons/openfairway/physics/RolloutProfile.cs
+++ b/addons/openfairway/physics/RolloutProfile.cs
@@ -22,6 +22,7 @@ public sealed class RolloutProfile
// --- Friction blending ---
public float FrictionBlendSpeed { get; init; } = 15.0f;
+ public float TangentVelocityThreshold { get; init; } = 0.05f;
// --- Metadata ---
public string Name { get; init; } = "Default";
diff --git a/game/GolfBall.cs b/game/GolfBall.cs
index 5bb6478..cc6a1d9 100644
--- a/game/GolfBall.cs
+++ b/game/GolfBall.cs
@@ -320,35 +320,15 @@ private bool CheckOutOfBounds()
private bool TryRecoverToGround()
{
- var world = GetWorld3D();
- if (world == null)
+ var probe = TerrainProbe.Raycast(GetWorld3D(), GlobalPosition, GROUND_RAYCAST_UP, GROUND_RAYCAST_DOWN, GetRaycastExclude());
+ if (probe is not { } hit)
return false;
- Vector3 rayStart = GlobalPosition + Vector3.Up * GROUND_RAYCAST_UP;
- Vector3 rayEnd = GlobalPosition + Vector3.Down * GROUND_RAYCAST_DOWN;
-
- var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd);
- query.CollideWithAreas = false;
- query.CollideWithBodies = true;
- query.Exclude = GetRaycastExclude();
-
- var hit = world.DirectSpaceState.IntersectRay(query);
- if (hit.Count == 0)
- return false;
-
- Vector3 hitPosition = (Vector3)hit["position"];
- Vector3 hitNormal = ((Vector3)hit["normal"]).Normalized();
- Node hitCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider
- ? collider
- : null;
- if (hitNormal.LengthSquared() < 0.000001f)
- hitNormal = Vector3.Up;
-
- GlobalPosition = hitPosition + hitNormal * (BallPhysics.RADIUS + GROUND_SNAP_OFFSET);
- FloorNormal = hitNormal;
- Velocity = RemoveVelocityAlongNormal(Velocity, hitNormal, removeBothDirections: false);
+ GlobalPosition = hit.Position + hit.Normal * (BallPhysics.RADIUS + GROUND_SNAP_OFFSET);
+ FloorNormal = hit.Normal;
+ Velocity = RemoveVelocityAlongNormal(Velocity, hit.Normal, removeBothDirections: false);
OnGround = true;
- UpdateLieSurfaceFromContact(hitCollider, hitPosition);
+ UpdateLieSurfaceFromContact(hit.Collider, hit.Position);
if (State == PhysicsEnums.BallState.Flight)
{
@@ -356,7 +336,7 @@ private bool TryRecoverToGround()
EmitSignal(SignalName.BallLanded);
}
- PhysicsLogger.Verbose($"Recovered ball-to-ground at {GlobalPosition} (normal: {hitNormal})");
+ PhysicsLogger.Verbose($"Recovered ball-to-ground at {GlobalPosition} (normal: {hit.Normal})");
return true;
}
@@ -366,30 +346,13 @@ private bool TryProbeGround(out Vector3 groundNormal, out Node groundCollider, o
groundCollider = null;
groundPoint = GlobalPosition;
- var world = GetWorld3D();
- if (world == null)
+ var probe = TerrainProbe.Raycast(GetWorld3D(), GlobalPosition, 0.05f, BallPhysics.RADIUS + GROUND_PROBE_DISTANCE, GetRaycastExclude());
+ if (probe is not { } hit)
return false;
- Vector3 rayStart = GlobalPosition + Vector3.Up * 0.05f;
- Vector3 rayEnd = GlobalPosition + Vector3.Down * (BallPhysics.RADIUS + GROUND_PROBE_DISTANCE);
-
- var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd);
- query.CollideWithAreas = false;
- query.CollideWithBodies = true;
- query.Exclude = GetRaycastExclude();
-
- var hit = world.DirectSpaceState.IntersectRay(query);
- if (hit.Count == 0)
- return false;
-
- groundPoint = (Vector3)hit["position"];
- groundNormal = ((Vector3)hit["normal"]).Normalized();
- groundCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider
- ? collider
- : null;
- if (groundNormal.LengthSquared() < 0.000001f)
- groundNormal = Vector3.Up;
-
+ groundPoint = hit.Position;
+ groundNormal = hit.Normal;
+ groundCollider = hit.Collider;
return true;
}
@@ -536,24 +499,11 @@ public void SnapToGround()
}
// Fallback: physics raycast (requires collision shapes to be ready).
- var world = GetWorld3D();
- if (world == null)
- return;
-
- Vector3 rayStart = GlobalPosition + Vector3.Up * 50.0f;
- Vector3 rayEnd = GlobalPosition + Vector3.Down * 50.0f;
-
- var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd);
- query.CollideWithAreas = false;
- query.CollideWithBodies = true;
- query.Exclude = GetRaycastExclude();
-
- var hit = world.DirectSpaceState.IntersectRay(query);
- if (hit.Count == 0)
+ var probe = TerrainProbe.Raycast(GetWorld3D(), GlobalPosition, 50.0f, 50.0f, GetRaycastExclude());
+ if (probe is not { } hit)
return;
- Vector3 hitPosition = (Vector3)hit["position"];
- GlobalPosition = new Vector3(GlobalPosition.X, hitPosition.Y + TEE_HEIGHT, GlobalPosition.Z);
+ GlobalPosition = new Vector3(GlobalPosition.X, hit.Position.Y + TEE_HEIGHT, GlobalPosition.Z);
}
///
diff --git a/game/TerrainProbe.cs b/game/TerrainProbe.cs
new file mode 100644
index 0000000..57ad3c7
--- /dev/null
+++ b/game/TerrainProbe.cs
@@ -0,0 +1,49 @@
+using Godot;
+using Godot.Collections;
+
+///
+/// Reusable terrain raycast utility.
+/// Encapsulates the common pattern of casting a vertical ray through the physics world
+/// and parsing the hit dictionary into a structured result.
+///
+public static class TerrainProbe
+{
+ public readonly record struct TerrainHit(Vector3 Position, Vector3 Normal, Node Collider);
+
+ ///
+ /// Cast a vertical ray from (origin + Up * upOffset) to (origin + Down * downOffset).
+ /// Returns null if no hit or world is unavailable.
+ ///
+ public static TerrainHit? Raycast(
+ World3D world,
+ Vector3 origin,
+ float upOffset,
+ float downOffset,
+ Array exclude)
+ {
+ if (world == null)
+ return null;
+
+ Vector3 rayStart = origin + Vector3.Up * upOffset;
+ Vector3 rayEnd = origin + Vector3.Down * downOffset;
+
+ var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd);
+ query.CollideWithAreas = false;
+ query.CollideWithBodies = true;
+ query.Exclude = exclude;
+
+ var hit = world.DirectSpaceState.IntersectRay(query);
+ if (hit.Count == 0)
+ return null;
+
+ Vector3 position = (Vector3)hit["position"];
+ Vector3 normal = ((Vector3)hit["normal"]).Normalized();
+ Node collider = hit.ContainsKey("collider") && hit["collider"].Obj is Node node
+ ? node
+ : null;
+ if (normal.LengthSquared() < 0.000001f)
+ normal = Vector3.Up;
+
+ return new TerrainHit(position, normal, collider);
+ }
+}
diff --git a/game/TerrainProbe.cs.uid b/game/TerrainProbe.cs.uid
new file mode 100644
index 0000000..45b1416
--- /dev/null
+++ b/game/TerrainProbe.cs.uid
@@ -0,0 +1 @@
+uid://cjqe75xo6wcys
diff --git a/game/hole/HoleAudioManager.cs b/game/hole/HoleAudioManager.cs
new file mode 100644
index 0000000..8f0ae6c
--- /dev/null
+++ b/game/hole/HoleAudioManager.cs
@@ -0,0 +1,63 @@
+using Godot;
+
+///
+/// Manages hole scene audio: non-attenuated 3D audio configuration, driver hit, and ball landing sounds.
+///
+public sealed class HoleAudioManager
+{
+ private readonly AudioStreamPlayer3D _driverHit;
+ private readonly AudioStreamPlayer3D _ambientBirds;
+ private readonly AudioStreamPlayer3D _ballLanding;
+
+ public HoleAudioManager(
+ AudioStreamPlayer3D driverHit,
+ AudioStreamPlayer3D ambientBirds,
+ AudioStreamPlayer3D ballLanding)
+ {
+ _driverHit = driverHit;
+ _ambientBirds = ambientBirds;
+ _ballLanding = ballLanding;
+ }
+
+ public void ConfigureAll(bool startAmbient)
+ {
+ ConfigureNonAttenuated(_ambientBirds, ensurePlaying: startAmbient);
+ ConfigureNonAttenuated(_driverHit, ensurePlaying: false);
+ ConfigureNonAttenuated(_ballLanding, ensurePlaying: false);
+ }
+
+ public void PlayDriverHit()
+ {
+ if (_driverHit == null)
+ return;
+
+ if (_driverHit.Playing)
+ _driverHit.Stop();
+
+ _driverHit.Play();
+ }
+
+ public void PlayBallLanding(Vector3 ballPosition)
+ {
+ if (_ballLanding == null)
+ return;
+
+ _ballLanding.GlobalPosition = ballPosition;
+ if (_ballLanding.Playing)
+ _ballLanding.Stop();
+
+ _ballLanding.Play();
+ }
+
+ private static void ConfigureNonAttenuated(AudioStreamPlayer3D player, bool ensurePlaying)
+ {
+ if (player == null)
+ return;
+
+ player.AttenuationModel = AudioStreamPlayer3D.AttenuationModelEnum.Disabled;
+ player.DopplerTracking = AudioStreamPlayer3D.DopplerTrackingEnum.Disabled;
+
+ if (ensurePlaying && !player.Playing)
+ player.Play();
+ }
+}
diff --git a/game/hole/HoleAudioManager.cs.uid b/game/hole/HoleAudioManager.cs.uid
new file mode 100644
index 0000000..86e5fcc
--- /dev/null
+++ b/game/hole/HoleAudioManager.cs.uid
@@ -0,0 +1 @@
+uid://cjtq3iowwfu1g
diff --git a/game/hole/HoleSceneControllerBase.cs b/game/hole/HoleSceneControllerBase.cs
index 02b25b6..58b9774 100644
--- a/game/hole/HoleSceneControllerBase.cs
+++ b/game/hole/HoleSceneControllerBase.cs
@@ -57,6 +57,7 @@ public abstract partial class HoleSceneControllerBase : Node3D
private AudioStreamPlayer3D _audioDriverHit;
private AudioStreamPlayer3D _audioBackgroundBirds;
private AudioStreamPlayer3D _audioGolfBallLanding;
+ private HoleAudioManager _audioManager;
private TcpServer _tcpServer;
private ShotRecordingService _shotRecordingService;
private GameSettings _gameSettings;
@@ -289,7 +290,8 @@ private void InitializeCoreStage()
_audioDriverHit = GetNodeOrNull(DriverHitAudioPath);
_audioBackgroundBirds = GetNodeOrNull(AmbientAudioPath);
_audioGolfBallLanding = GetNodeOrNull(BallLandingAudioPath);
- ConfigureConsistentAudioLevels(startAmbientAudio: false);
+ _audioManager = new HoleAudioManager(_audioDriverHit, _audioBackgroundBirds, _audioGolfBallLanding);
+ _audioManager.ConfigureAll(startAmbient: false);
_progressStore = GetNodeOrNull("/root/GameProgressStore");
_sceneId = GetSceneId();
ResolveCourseCard();
@@ -378,7 +380,7 @@ private void BeginBackgroundStartupStage()
_startupStage = StartupStage.Background;
bool shouldPlayAmbientAudio = ShouldPlayAmbientAudioOnStartup();
- ConfigureNonAttenuated3DAudio(_audioBackgroundBirds, ensurePlaying: shouldPlayAmbientAudio);
+ _audioManager.ConfigureAll(startAmbient: shouldPlayAmbientAudio);
if (!shouldPlayAmbientAudio && _audioBackgroundBirds != null && _audioBackgroundBirds.Playing)
_audioBackgroundBirds.Stop();
@@ -590,7 +592,7 @@ private async void OnGolfBallRest()
private void OnGolfBallLanded()
{
- PlayGolfBallLandingAudio();
+ _audioManager.PlayBallLanding(_ball.GlobalPosition);
}
private void OnGameplayUiHitShot(Dictionary data)
@@ -617,16 +619,7 @@ private void OnTestHitRequested()
LaunchShot(data, useTcpTracker: false, logPayload: false);
}
- private void PlayDriverHitAudio()
- {
- if (_audioDriverHit == null)
- return;
- if (_audioDriverHit.Playing)
- _audioDriverHit.Stop();
-
- _audioDriverHit.Play();
- }
private void LaunchShot(Dictionary data, bool useTcpTracker, bool logPayload)
{
@@ -644,7 +637,7 @@ private void LaunchShot(Dictionary data, bool useTcpTracker, bool logPayload)
_displaySession.SetRawPayload(data);
UpdateBallDisplay();
- PlayDriverHitAudio();
+ _audioManager.PlayDriverHit();
IncrementStrokeCount();
_shotRecordingService?.RecordShot(data, ResolveShotRecordingClubTag());
@@ -657,36 +650,6 @@ private void LaunchShot(Dictionary data, bool useTcpTracker, bool logPayload)
_ = _shotCameraController.EnableFollowDeferredAsync();
}
- private void ConfigureConsistentAudioLevels(bool startAmbientAudio)
- {
- ConfigureNonAttenuated3DAudio(_audioBackgroundBirds, ensurePlaying: startAmbientAudio);
- ConfigureNonAttenuated3DAudio(_audioDriverHit, ensurePlaying: false);
- ConfigureNonAttenuated3DAudio(_audioGolfBallLanding, ensurePlaying: false);
- }
-
- private void ConfigureNonAttenuated3DAudio(AudioStreamPlayer3D player, bool ensurePlaying)
- {
- if (player == null)
- return;
-
- player.AttenuationModel = AudioStreamPlayer3D.AttenuationModelEnum.Disabled;
- player.DopplerTracking = AudioStreamPlayer3D.DopplerTrackingEnum.Disabled;
-
- if (ensurePlaying && !player.Playing)
- player.Play();
- }
-
- private void PlayGolfBallLandingAudio()
- {
- if (_audioGolfBallLanding == null)
- return;
-
- _audioGolfBallLanding.GlobalPosition = _ball.GlobalPosition;
- if (_audioGolfBallLanding.Playing)
- _audioGolfBallLanding.Stop();
-
- _audioGolfBallLanding.Play();
- }
private void OnCameraFollowChanged(Variant value)
{
diff --git a/ui/CourseHud.cs b/ui/CourseHud.cs
index 01c74ef..46c0f19 100644
--- a/ui/CourseHud.cs
+++ b/ui/CourseHud.cs
@@ -620,21 +620,11 @@ private void DisconnectRangeControlSignals()
_rangeClubOption.ItemSelected -= OnRangeClubSelected;
}
- private void OnRangeTargetSliderChanged(double value)
- {
- if (_isSyncingRangeControls)
- return;
+ private void OnRangeTargetSliderChanged(double value) => SyncRangeTargetValue(value);
- _rangeTargetYards = Mathf.Clamp(Mathf.RoundToInt((float)value), _rangeTargetMinYards, _rangeTargetMaxYards);
- _isSyncingRangeControls = true;
- if (_rangeTargetStepper != null)
- _rangeTargetStepper.Value = _rangeTargetYards;
- if (_rangeTargetSlider != null)
- _rangeTargetSlider.Value = _rangeTargetYards;
- _isSyncingRangeControls = false;
- }
+ private void OnRangeTargetStepperChanged(double value) => SyncRangeTargetValue(value);
- private void OnRangeTargetStepperChanged(double value)
+ private void SyncRangeTargetValue(double value)
{
if (_isSyncingRangeControls)
return;
diff --git a/ui/SettingsPanel.cs b/ui/SettingsPanel.cs
index ddff312..fe533ae 100644
--- a/ui/SettingsPanel.cs
+++ b/ui/SettingsPanel.cs
@@ -66,17 +66,8 @@ public enum SettingsTab
private GlobalSettings _globalSettings;
private GameSettings _gameSettings;
private AppSettings _appSettings;
- private Setting _playerNameSetting;
- private Setting _testShotsSetting;
- private Setting _resolutionSetting;
- private Setting _fullscreenSetting;
- private Setting _cameraDistanceSetting;
- private Setting _cameraDelaySetting;
- private Setting _tcpPortSetting;
- private Setting _shotRecordingEnabledSetting;
- private Setting _shotRecordingPathSetting;
private Setting _shotTracerCountSetting;
- private Setting _rangeDefaultClubSetting;
+ private readonly List _settingSubscriptions = new();
private ShotRecordingService _shotRecordingService;
private bool _isSyncingControls;
private bool _isSyncingPanelsGrid;
@@ -379,58 +370,31 @@ private void ConnectSettingSignals()
{
if (_appSettings != null)
{
- _playerNameSetting = _appSettings.PlayerName;
- _testShotsSetting = _appSettings.TestShotsEnabled;
- _resolutionSetting = _appSettings.DisplayResolutionPreset;
- _fullscreenSetting = _appSettings.DisplayFullscreen;
- _cameraDistanceSetting = _appSettings.CameraOrbitDistance;
- _cameraDelaySetting = _appSettings.CameraFollowDelaySeconds;
- _tcpPortSetting = _appSettings.TcpPort;
- _shotRecordingEnabledSetting = _appSettings.ShotRecordingEnabled;
- _shotRecordingPathSetting = _appSettings.ShotRecordingPath;
- _rangeDefaultClubSetting = _appSettings.RangeDefaultClub;
-
- _playerNameSetting.SettingChanged += OnAnySettingChanged;
- _testShotsSetting.SettingChanged += OnAnySettingChanged;
- _resolutionSetting.SettingChanged += OnAnySettingChanged;
- _fullscreenSetting.SettingChanged += OnAnySettingChanged;
- _cameraDistanceSetting.SettingChanged += OnAnySettingChanged;
- _cameraDelaySetting.SettingChanged += OnAnySettingChanged;
- _tcpPortSetting.SettingChanged += OnAnySettingChanged;
- _shotRecordingEnabledSetting.SettingChanged += OnAnySettingChanged;
- _shotRecordingPathSetting.SettingChanged += OnAnySettingChanged;
- _rangeDefaultClubSetting.SettingChanged += OnAnySettingChanged;
+ SubscribeSetting(_appSettings.PlayerName);
+ SubscribeSetting(_appSettings.TestShotsEnabled);
+ SubscribeSetting(_appSettings.DisplayResolutionPreset);
+ SubscribeSetting(_appSettings.DisplayFullscreen);
+ SubscribeSetting(_appSettings.CameraOrbitDistance);
+ SubscribeSetting(_appSettings.CameraFollowDelaySeconds);
+ SubscribeSetting(_appSettings.TcpPort);
+ SubscribeSetting(_appSettings.ShotRecordingEnabled);
+ SubscribeSetting(_appSettings.ShotRecordingPath);
+ SubscribeSetting(_appSettings.RangeDefaultClub);
}
_shotTracerCountSetting = _gameSettings?.ShotTracerCount;
if (_shotTracerCountSetting != null)
- _shotTracerCountSetting.SettingChanged += OnAnySettingChanged;
+ SubscribeSetting(_shotTracerCountSetting);
+ }
+
+ private void SubscribeSetting(Setting setting)
+ {
+ _settingSubscriptions.Add(new SettingSubscription(setting, OnAnySettingChanged));
}
private void DisconnectSettingSignals()
{
- if (_playerNameSetting != null)
- _playerNameSetting.SettingChanged -= OnAnySettingChanged;
- if (_testShotsSetting != null)
- _testShotsSetting.SettingChanged -= OnAnySettingChanged;
- if (_resolutionSetting != null)
- _resolutionSetting.SettingChanged -= OnAnySettingChanged;
- if (_fullscreenSetting != null)
- _fullscreenSetting.SettingChanged -= OnAnySettingChanged;
- if (_cameraDistanceSetting != null)
- _cameraDistanceSetting.SettingChanged -= OnAnySettingChanged;
- if (_cameraDelaySetting != null)
- _cameraDelaySetting.SettingChanged -= OnAnySettingChanged;
- if (_tcpPortSetting != null)
- _tcpPortSetting.SettingChanged -= OnAnySettingChanged;
- if (_shotRecordingEnabledSetting != null)
- _shotRecordingEnabledSetting.SettingChanged -= OnAnySettingChanged;
- if (_shotRecordingPathSetting != null)
- _shotRecordingPathSetting.SettingChanged -= OnAnySettingChanged;
- if (_rangeDefaultClubSetting != null)
- _rangeDefaultClubSetting.SettingChanged -= OnAnySettingChanged;
- if (_shotTracerCountSetting != null)
- _shotTracerCountSetting.SettingChanged -= OnAnySettingChanged;
+ SettingSubscription.DisposeAll(_settingSubscriptions);
}
private void OnAnySettingChanged(Variant _value)
diff --git a/utils/Settings/SettingSubscription.cs b/utils/Settings/SettingSubscription.cs
new file mode 100644
index 0000000..1798169
--- /dev/null
+++ b/utils/Settings/SettingSubscription.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+
+///
+/// Lightweight subscription wrapper for a Setting's SettingChanged signal.
+/// Disposes cleanly to prevent signal leaks.
+///
+public sealed class SettingSubscription : IDisposable
+{
+ private readonly Setting _setting;
+ private readonly Setting.SettingChangedEventHandler _handler;
+ private bool _disposed;
+
+ public SettingSubscription(Setting setting, Setting.SettingChangedEventHandler handler)
+ {
+ _setting = setting;
+ _handler = handler;
+ _setting.SettingChanged += _handler;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _setting.SettingChanged -= _handler;
+ }
+
+ ///
+ /// Disposes all subscriptions in the list and clears it.
+ ///
+ public static void DisposeAll(List subscriptions)
+ {
+ foreach (var sub in subscriptions)
+ sub.Dispose();
+
+ subscriptions.Clear();
+ }
+}
diff --git a/utils/Settings/SettingSubscription.cs.uid b/utils/Settings/SettingSubscription.cs.uid
new file mode 100644
index 0000000..27b05a3
--- /dev/null
+++ b/utils/Settings/SettingSubscription.cs.uid
@@ -0,0 +1 @@
+uid://b2lysvwsdsbm5