From d61cc8e4aab391c2c8b482fb75d3c5fb49b586a4 Mon Sep 17 00:00:00 2001 From: Jesse Hernandez Date: Fri, 13 Mar 2026 14:56:50 -0700 Subject: [PATCH] feat: refactoring, reusability, simplifying. --- addons/openfairway/physics/BallPhysics.cs | 653 +----------------- .../openfairway/physics/BounceCalculator.cs | 550 +++++++++++++++ .../physics/BounceCalculator.cs.uid | 1 + addons/openfairway/physics/RolloutProfile.cs | 1 + game/GolfBall.cs | 80 +-- game/TerrainProbe.cs | 49 ++ game/TerrainProbe.cs.uid | 1 + game/hole/HoleAudioManager.cs | 63 ++ game/hole/HoleAudioManager.cs.uid | 1 + game/hole/HoleSceneControllerBase.cs | 49 +- ui/CourseHud.cs | 16 +- ui/SettingsPanel.cs | 72 +- utils/Settings/SettingSubscription.cs | 40 ++ utils/Settings/SettingSubscription.cs.uid | 1 + 14 files changed, 763 insertions(+), 814 deletions(-) create mode 100644 addons/openfairway/physics/BounceCalculator.cs create mode 100644 addons/openfairway/physics/BounceCalculator.cs.uid create mode 100644 game/TerrainProbe.cs create mode 100644 game/TerrainProbe.cs.uid create mode 100644 game/hole/HoleAudioManager.cs create mode 100644 game/hole/HoleAudioManager.cs.uid create mode 100644 utils/Settings/SettingSubscription.cs create mode 100644 utils/Settings/SettingSubscription.cs.uid 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