diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/flippers.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/flippers.md
index 1da781e92..965ec79e2 100644
--- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/flippers.md
+++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/flippers.md
@@ -60,11 +60,19 @@ Gameplay-wise, the effect of this parameter is most strongly felt in situations
Note that increasing this setting will decrease the speed of the flipper a bit and may need to be compensated with a higher Strength setting. Also, if this parameter is set too high, the flipper may feel sluggish and laggy.
-#### EOS Torque and Angle
-
-The "end of stroke" torque is the force that holds the flipper up once it reached the end position. The angle defines how many degrees from the end position that force is applied.
-
-#### Flipper Correction
+#### EOS Torque and Angle
+
+The "end of stroke" torque is the force that holds the flipper up once it reached the end position. The angle defines how many degrees from the end position that force is applied.
+
+#### Live Catch and EOS Rubber Dampening
+
+Enabling *VPW Catch Physics* adds the modern VPW live-catch response. A ball arriving shortly after the flipper reaches its end position can be caught completely or receive a small timing-dependent bounce. Impacts near the flipper base use the configured *Base Dampen* value instead.
+
+When an impact does not qualify for the live-catch window, a held flipper also applies VPW's low-speed EOS rubber correction. This correction targets the total incoming-to-outgoing speed ratio of slow, nearly vertical rebounds and is only active while the flipper button remains pressed at end of stroke. It does not replace the flipper's normal elasticity, friction, or collision response.
+
+The live-catch distance, timing, bounce, and base-zone values can be adjusted in the flipper collider inspector. The EOS rubber profile uses the standard VPW curve.
+
+#### Flipper Correction
This is where you can set a profile for nFozzy's flipper physics. Profiles are files in your asset folder that you can create and modify. VPE ships with three profiles based on nFozzy's measurements that cover the solid state era of pinball machines. EM machines usually don't need flipper correction.
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs
index 644af2c74..4d43b24e3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs
@@ -157,9 +157,9 @@ public override void OnInspectorGUI()
}
EditorGUILayout.EndFoldoutHeaderGroup();
- if (_foldoutLiveCatch = EditorGUILayout.BeginFoldoutHeaderGroup(_foldoutLiveCatch, "Live Catch")) {
-
- PropertyField(_useFlipperLiveCatch, "Use Live Catch");
+ if (_foldoutLiveCatch = EditorGUILayout.BeginFoldoutHeaderGroup(_foldoutLiveCatch, "Live Catch & EOS Rubber")) {
+
+ PropertyField(_useFlipperLiveCatch, "Use VPW Catch Physics");
EditorGUI.BeginDisabledGroup(!_useFlipperLiveCatch.boolValue);
PropertyField(_liveCatchDistanceMin, "Min Distance");
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs
new file mode 100644
index 000000000..7f1ef0901
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs
@@ -0,0 +1,365 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using NativeTrees;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Engine.VPT;
+
+namespace VisualPinball.Unity.Test
+{
+ public class FlipperCollisionIntegrationTests
+ {
+ [Test]
+ public void EosRubberRunsAfterNormalAndFrictionResponse()
+ {
+ var normalOnly = RunCollision(float4x4.identity, false, false, false, 50f, 0f);
+ var baseline = RunCollision(float4x4.identity, false, false, false, 50f, 0.2f);
+ var rubberized = RunCollision(float4x4.identity, false, true, false, 50f, 0.2f);
+ var incomingSpeed = math.length(new float3(-5f, -1f, 0f));
+ var expectedScale = FlipperCollider.EosRubberDesiredCor(incomingSpeed) * incomingSpeed
+ / math.length(baseline.Velocity);
+
+ Assert.That(math.distance(baseline.Velocity, new float3(-5f, -1f, 0f)), Is.GreaterThan(0.1f));
+ Assert.That(math.distance(baseline.Velocity, normalOnly.Velocity), Is.GreaterThan(1e-4f));
+ AssertFloat3(rubberized.Velocity, baseline.Velocity * expectedScale);
+ AssertFloat3(rubberized.AngularMomentum, baseline.AngularMomentum);
+ }
+
+ [Test]
+ public void FlipperMotionDoesNotQualifySlowBallForLiveCatch()
+ {
+ var slowBallVelocity = new float3(-1f, -1f, 0f);
+ var withoutWindow = RunCollision(float4x4.identity, false, true, false, 50f, 0.15f,
+ slowBallVelocity, 0.1f);
+ var withWindow = RunCollision(float4x4.identity, false, true, true, 50f, 0.15f,
+ slowBallVelocity, 0.1f);
+
+ AssertFloat3(withWindow.Velocity, withoutWindow.Velocity);
+ AssertFloat3(withWindow.AngularMomentum, withoutWindow.AngularMomentum);
+ }
+
+ [TestCase(-10f, false)]
+ [TestCase(10f, false)]
+ [TestCase(-10f, true)]
+ [TestCase(10f, true)]
+ public void MirroredZRotationsPreservePlayfieldRubberResponse(float angleDegrees, bool isKinematic)
+ {
+ var matrix = float4x4.TRS(
+ new float3(25f, -10f, 5f),
+ quaternion.RotateZ(math.radians(angleDegrees)),
+ new float3(1f)
+ );
+ var baseline = RunCollision(float4x4.identity, false, true, false, 50f, 0.15f);
+ var transformed = RunCollision(matrix, isKinematic, true, false, 50f, 0.15f);
+
+ AssertFloat3(transformed.Velocity, matrix.MultiplyVector(baseline.Velocity));
+ AssertFloat3(transformed.AngularMomentum, matrix.MultiplyVector(baseline.AngularMomentum));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void RuntimeLocalTransformPreservesPlayfieldRubberResponse(bool isKinematic)
+ {
+ var matrix = float4x4.TRS(
+ new float3(25f, -10f, 5f),
+ quaternion.RotateX(math.radians(10f)),
+ new float3(1f)
+ );
+ var baseline = RunCollision(float4x4.identity, false, true, false, 50f, 0.15f);
+ var transformed = RunCollision(matrix, isKinematic, true, false, 50f, 0.15f);
+
+ AssertFloat3(transformed.Velocity, matrix.MultiplyVector(baseline.Velocity));
+ AssertFloat3(transformed.AngularMomentum, matrix.MultiplyVector(baseline.AngularMomentum));
+ }
+
+ [Test]
+ public void KinematicTransformUsesColliderToPlayfieldMatrixForVelocityGate()
+ {
+ var matrix = float4x4.TRS(
+ new float3(25f, -10f, 5f),
+ quaternion.RotateZ(math.radians(90f)),
+ new float3(1f)
+ );
+ var baseline = RunPlayfieldGateCollision(matrix, false);
+ var rubberized = RunPlayfieldGateCollision(matrix, true);
+ var incomingSpeed = math.length(new float3(0f, -1f, -5f));
+ var expectedScale = FlipperCollider.EosRubberDesiredCor(incomingSpeed) * incomingSpeed
+ / math.length(baseline.Velocity);
+
+ AssertFloat3(rubberized.Velocity, baseline.Velocity * expectedScale);
+ }
+
+ [Test]
+ public void LiveCatchBaseDampeningSuppressesEosRubberFallback()
+ {
+ var baseline = RunCollision(float4x4.identity, false, false, false, 20f, 0.15f);
+ var caught = RunCollision(float4x4.identity, false, true, true, 20f, 0.15f);
+
+ AssertFloat3(caught.Velocity, baseline.Velocity * 0.55f);
+ AssertFloat3(caught.AngularMomentum, baseline.AngularMomentum * 0.55f);
+ }
+
+ [Test]
+ public void EligibleLiveCatchWithoutVelocityChangeSuppressesEosRubberFallback()
+ {
+ var baseline = RunCollision(float4x4.identity, false, false, false, -20f, 0.15f);
+ var eligible = RunCollision(float4x4.identity, false, true, true, -20f, 0.15f);
+
+ AssertFloat3(eligible.Velocity, baseline.Velocity);
+ AssertFloat3(eligible.AngularMomentum, baseline.AngularMomentum);
+ }
+
+ [Test]
+ public void PerfectLiveCatchSuppressesEosRubberFallback()
+ {
+ var caught = RunCollision(float4x4.identity, false, true, true, 50f, 0.15f);
+
+ AssertFloat3(caught.Velocity, float3.zero);
+ AssertFloat3(caught.AngularMomentum, float3.zero);
+ }
+
+ private static CollisionResult RunCollision(float4x4 matrix, bool isKinematic,
+ bool useCatchPhysics, bool hasLiveCatchWindow, float distanceFromBase, float friction)
+ {
+ return RunCollision(matrix, isKinematic, useCatchPhysics, hasLiveCatchWindow, distanceFromBase,
+ friction, new float3(-5f, -1f, 0f), 0f);
+ }
+
+ private static CollisionResult RunCollision(float4x4 matrix, bool isKinematic,
+ bool useCatchPhysics, bool hasLiveCatchWindow, float distanceFromBase, float friction,
+ float3 localVelocity, float flipperAngleSpeed)
+ {
+ using var harness = new FlipperCollisionHarness(matrix, isKinematic, useCatchPhysics,
+ hasLiveCatchWindow, friction, flipperAngleSpeed);
+ var localPosition = new float3(0f, -distanceFromBase, 10f);
+ var localNormal = new float3(1f, 0f, 0f);
+ var ball = new BallState {
+ Id = 7,
+ Position = matrix.MultiplyPoint(localPosition),
+ EventPosition = matrix.MultiplyPoint(localPosition),
+ Velocity = matrix.MultiplyVector(localVelocity),
+ Radius = 1f,
+ Mass = 1f,
+ CollisionEvent = new CollisionEventData {
+ ColliderId = 0,
+ IsKinematic = isKinematic,
+ HitTime = 0f,
+ HitNormal = matrix.MultiplyVector(localNormal),
+ HitDistance = 0f,
+ }
+ };
+ var state = harness.CreateState();
+
+ PhysicsStaticCollision.Collide(0f, ref ball, ref state);
+
+ return new CollisionResult(ball.Velocity, ball.AngularMomentum);
+ }
+
+ private static CollisionResult RunPlayfieldGateCollision(float4x4 matrix, bool useCatchPhysics)
+ {
+ using var harness = new FlipperCollisionHarness(matrix, true, useCatchPhysics, false, 0f);
+ var localPosition = new float3(0f, -50f, 10f);
+ var ball = new BallState {
+ Id = 7,
+ Position = matrix.MultiplyPoint(localPosition),
+ EventPosition = matrix.MultiplyPoint(localPosition),
+ Velocity = new float3(0f, -1f, -5f),
+ Radius = 1f,
+ Mass = 1f,
+ CollisionEvent = new CollisionEventData {
+ ColliderId = 0,
+ IsKinematic = true,
+ HitTime = 0f,
+ HitNormal = new float3(0f, 0f, 1f),
+ HitDistance = 0f,
+ }
+ };
+ var state = harness.CreateState();
+
+ PhysicsStaticCollision.Collide(0f, ref ball, ref state);
+
+ return new CollisionResult(ball.Velocity, ball.AngularMomentum);
+ }
+
+ private static void AssertFloat3(float3 actual, float3 expected)
+ {
+ Assert.That(actual.x, Is.EqualTo(expected.x).Within(1e-4f));
+ Assert.That(actual.y, Is.EqualTo(expected.y).Within(1e-4f));
+ Assert.That(actual.z, Is.EqualTo(expected.z).Within(1e-4f));
+ }
+
+ private readonly struct CollisionResult
+ {
+ internal readonly float3 Velocity;
+ internal readonly float3 AngularMomentum;
+
+ internal CollisionResult(float3 velocity, float3 angularMomentum)
+ {
+ Velocity = velocity;
+ AngularMomentum = angularMomentum;
+ }
+ }
+
+ private sealed class FlipperCollisionHarness : IDisposable
+ {
+ private const int ItemId = 42;
+
+ private PhysicsEnv _env;
+ private NativeOctree _octree;
+ private NativeColliders _colliders;
+ private NativeColliders _kinematicColliders;
+ private NativeColliders _kinematicCollidersAtIdentity;
+ private NativeParallelHashMap _kinematicTransforms;
+ private NativeParallelHashMap _kinematicTargetTransforms;
+ private NativeParallelHashMap _nonTransformableColliderTransforms;
+ private NativeParallelHashMap _kinematicColliderLookups;
+ private NativeQueue _events;
+ private InsideOfs _insideOfs;
+ private NativeParallelHashMap _balls;
+ private NativeParallelHashMap _bumperStates;
+ private NativeParallelHashMap _dropTargetStates;
+ private NativeParallelHashMap _flipperStates;
+ private NativeParallelHashMap _gateStates;
+ private NativeParallelHashMap _hitTargetStates;
+ private NativeParallelHashMap _kickerStates;
+ private NativeParallelHashMap _magnetStates;
+ private NativeParallelHashMap _plungerStates;
+ private NativeParallelHashMap _spinnerStates;
+ private NativeParallelHashMap _surfaceStates;
+ private NativeParallelHashMap _turntableStates;
+ private NativeParallelHashMap _triggerStates;
+ private NativeParallelHashSet _disabledCollisionItems;
+ private bool _swapBallCollisionHandling;
+ private NativeParallelHashMap> _elasticityLuts;
+ private NativeParallelHashMap> _frictionLuts;
+ private NativeParallelHashMap _kinematicVelocities;
+ private ColliderReference _colliderReference;
+ private readonly bool _isKinematic;
+
+ internal FlipperCollisionHarness(float4x4 matrix, bool isKinematic, bool useCatchPhysics,
+ bool hasLiveCatchWindow, float friction, float flipperAngleSpeed = 0f)
+ {
+ _isKinematic = isKinematic;
+ _env = new PhysicsEnv { TimeMsec = 105 };
+ _kinematicTransforms = new NativeParallelHashMap(1, Allocator.Persistent);
+ _kinematicTargetTransforms = new NativeParallelHashMap(1, Allocator.Persistent);
+ _nonTransformableColliderTransforms = new NativeParallelHashMap(1, Allocator.Persistent);
+ _kinematicColliderLookups = new NativeParallelHashMap(1, Allocator.Persistent);
+ _events = new NativeQueue(Allocator.Persistent);
+ _insideOfs = new InsideOfs(Allocator.Persistent);
+ _balls = new NativeParallelHashMap(1, Allocator.Persistent);
+ _flipperStates = new NativeParallelHashMap(1, Allocator.Persistent);
+ _disabledCollisionItems = new NativeParallelHashSet(1, Allocator.Persistent);
+ _elasticityLuts = new NativeParallelHashMap>(1, Allocator.Persistent);
+ _frictionLuts = new NativeParallelHashMap>(1, Allocator.Persistent);
+ _kinematicVelocities = new NativeParallelHashMap(1, Allocator.Persistent);
+
+ _colliderReference = new ColliderReference(ref _nonTransformableColliderTransforms,
+ Allocator.Persistent, isKinematic);
+ var collider = new FlipperCollider(50f, 100f, 20f, 10f, 0f, 0f,
+ new ColliderInfo {
+ ItemId = ItemId,
+ ItemType = ItemType.Flipper,
+ Material = new PhysicsMaterialData {
+ Elasticity = 0f,
+ ElasticityFalloff = 0f,
+ Friction = friction,
+ },
+ });
+ _colliderReference.Add(collider, matrix);
+ if (isKinematic) {
+ _kinematicColliders = new NativeColliders(ref _colliderReference, Allocator.Persistent);
+ _kinematicTransforms.Add(ItemId, matrix);
+ } else {
+ _colliders = new NativeColliders(ref _colliderReference, Allocator.Persistent);
+ }
+
+ _flipperStates.Add(ItemId, new FlipperState(
+ new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 0f,
+ Inertia = 1000f,
+ },
+ new FlipperMovementState {
+ Angle = 0f,
+ AngleSpeed = flipperAngleSpeed,
+ AngularMomentum = flipperAngleSpeed * 1000f,
+ },
+ new FlipperVelocityData {
+ IsInContact = true,
+ ContactTorque = -1f,
+ },
+ new FlipperHitData(),
+ new FlipperTricksData {
+ UseFlipperLiveCatch = useCatchPhysics,
+ OriginalAngleEnd = 0f,
+ AngleEnd = 0f,
+ LiveCatchDistanceMin = 5f,
+ LiveCatchDistanceMax = 114f,
+ LiveCatchMinimalBallSpeed = 3f,
+ LiveCatchPerfectTime = 8f,
+ LiveCatchFullTime = 16f,
+ LiveCatchMinimalBounceSpeedMultiplier = 0f,
+ LiveCatchInaccurateBounceSpeedMultiplier = 32f,
+ LiveCatchBaseDampenDistance = 30f,
+ LiveCatchBaseDampen = 0.55f,
+ LiveCatchEosTimeMsec = 100,
+ HasLiveCatchEosTime = hasLiveCatchWindow,
+ },
+ new SolenoidState { Value = true }
+ ));
+ }
+
+ internal PhysicsState CreateState()
+ {
+ var writer = _events.AsParallelWriter();
+ return new PhysicsState(ref _env, ref _octree, ref _colliders, ref _kinematicColliders,
+ ref _kinematicCollidersAtIdentity, ref _kinematicTransforms, ref _kinematicTargetTransforms,
+ ref _nonTransformableColliderTransforms, ref _kinematicColliderLookups, ref writer,
+ ref _insideOfs, ref _balls, ref _bumperStates, ref _dropTargetStates, ref _flipperStates,
+ ref _gateStates, ref _hitTargetStates, ref _kickerStates, ref _magnetStates,
+ ref _plungerStates, ref _spinnerStates, ref _surfaceStates, ref _turntableStates,
+ ref _triggerStates, ref _disabledCollisionItems, ref _swapBallCollisionHandling,
+ ref _elasticityLuts, ref _frictionLuts, ref _kinematicVelocities);
+ }
+
+ public void Dispose()
+ {
+ if (_isKinematic) {
+ _kinematicColliders.Dispose();
+ } else {
+ _colliders.Dispose();
+ }
+ _colliderReference.Dispose();
+ _kinematicTransforms.Dispose();
+ _kinematicTargetTransforms.Dispose();
+ _nonTransformableColliderTransforms.Dispose();
+ _kinematicColliderLookups.Dispose();
+ _events.Dispose();
+ _insideOfs.Dispose();
+ _balls.Dispose();
+ _flipperStates.Dispose();
+ _disabledCollisionItems.Dispose();
+ _elasticityLuts.Dispose();
+ _frictionLuts.Dispose();
+ _kinematicVelocities.Dispose();
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs.meta
new file mode 100644
index 000000000..48dfde288
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperCollisionIntegrationTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9ce6852de6cf48c88c86ffa3bf3634ab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs
new file mode 100644
index 000000000..c7c1b8caf
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs
@@ -0,0 +1,449 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+
+namespace VisualPinball.Unity.Test
+{
+ public class FlipperPhysicsTests
+ {
+ [Test]
+ public void EosTimestampUsesPhysicsClock()
+ {
+ var movement = new FlipperMovementState {
+ Angle = 0f,
+ AngleSpeed = 1f,
+ AngularMomentum = 1f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ AngleEnd = 0.5f,
+ };
+ var staticData = new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 0.5f,
+ Inertia = 1f,
+ };
+ using var events = new NativeQueue(Allocator.Temp);
+ var writer = events.AsParallelWriter();
+
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 1234, 0.5f, ref writer);
+
+ Assert.That(tricks.HasLiveCatchEosTime, Is.True);
+ Assert.That(tricks.LiveCatchEosTimeMsec, Is.EqualTo(1234));
+ }
+
+ [Test]
+ public void EosTimestampUsesPhysicsClockForNegativeRotation()
+ {
+ var movement = new FlipperMovementState {
+ Angle = 1f,
+ AngleSpeed = -1f,
+ AngularMomentum = -1f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ AngleEnd = 0.5f,
+ };
+ var staticData = new FlipperStaticData {
+ AngleStart = 1f,
+ AngleEnd = 0.5f,
+ Inertia = 1f,
+ };
+ using var events = new NativeQueue(Allocator.Temp);
+ var writer = events.AsParallelWriter();
+
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 4321, 0.5f, ref writer);
+
+ Assert.That(tricks.HasLiveCatchEosTime, Is.True);
+ Assert.That(tricks.LiveCatchEosTimeMsec, Is.EqualTo(4321));
+ }
+
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ public void SolenoidEdgeInvalidatesEosTimestampWithoutFlipperTricks(bool solenoidValue, bool previousSolenoidValue)
+ {
+ var state = new FlipperState(
+ new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 1f,
+ Inertia = 1f,
+ ReturnRatio = 1f,
+ },
+ new FlipperMovementState(),
+ new FlipperVelocityData { Direction = true },
+ new FlipperHitData(),
+ new FlipperTricksData {
+ AngleEnd = 1f,
+ HasLiveCatchEosTime = true,
+ lastSolState = previousSolenoidValue,
+ },
+ new SolenoidState { Value = solenoidValue }
+ );
+
+ FlipperVelocityPhysics.UpdateVelocities(ref state);
+
+ Assert.That(state.Tricks.HasLiveCatchEosTime, Is.False);
+ Assert.That(state.Tricks.lastSolState, Is.EqualTo(solenoidValue));
+ }
+
+ [Test]
+ public void EosTimestampIsLatchedOnlyOncePerUpstroke()
+ {
+ var movement = new FlipperMovementState {
+ Angle = 0f,
+ AngleSpeed = 1f,
+ AngularMomentum = 1f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ AngleEnd = 0.5f,
+ };
+ var staticData = new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 0.5f,
+ Inertia = 1f,
+ };
+ using var events = new NativeQueue(Allocator.Temp);
+ var writer = events.AsParallelWriter();
+
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 1234, 0.5f, ref writer);
+ movement.Angle = tricks.AngleEnd;
+ movement.AngleSpeed = 0f;
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 4321, 0.5f, ref writer);
+
+ Assert.That(tricks.HasLiveCatchEosTime, Is.True);
+ Assert.That(tricks.LiveCatchEosTimeMsec, Is.EqualTo(1234));
+ }
+
+ [Test]
+ public void RepressAtEosDoesNotRearmLiveCatchWindow()
+ {
+ var movement = new FlipperMovementState {
+ Angle = 0.5f,
+ AngleSpeed = 0f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ AngleEnd = 0.5f,
+ LiveCatchEosTimeMsec = 1234,
+ HasLiveCatchEosTime = false,
+ };
+ var staticData = new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 0.5f,
+ Inertia = 1f,
+ };
+ using var events = new NativeQueue(Allocator.Temp);
+ var writer = events.AsParallelWriter();
+
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 4321, 0.5f, ref writer);
+
+ Assert.That(tricks.HasLiveCatchEosTime, Is.False);
+ Assert.That(tricks.LiveCatchEosTimeMsec, Is.EqualTo(1234));
+ }
+
+ [Test]
+ public void EosTimestampUsesActiveOvershootAngle()
+ {
+ var movement = new FlipperMovementState {
+ Angle = 0.5f,
+ AngleSpeed = 0.5f,
+ AngularMomentum = 0.5f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ AngleEnd = 0.6f,
+ };
+ var staticData = new FlipperStaticData {
+ AngleStart = 0f,
+ AngleEnd = 0.5f,
+ Inertia = 1f,
+ };
+ using var events = new NativeQueue(Allocator.Temp);
+ var writer = events.AsParallelWriter();
+
+ FlipperDisplacementPhysics.UpdateDisplacement(1, ref movement, ref tricks, in staticData,
+ true, 2468, 0.2f, ref writer);
+
+ Assert.That(movement.Angle, Is.EqualTo(0.6f).Within(1e-5f));
+ Assert.That(tricks.HasLiveCatchEosTime, Is.True);
+ Assert.That(tricks.LiveCatchEosTimeMsec, Is.EqualTo(2468));
+ }
+
+ [Test]
+ public void LiveCatchUsesCapturedPreImpactSpeed()
+ {
+ var ball = new BallState {
+ Position = new float3(-50f, 0f, 0f),
+ Velocity = new float3(0f, 4f, 0f),
+ };
+ var collEvent = new CollisionEventData {
+ HitNormal = new float3(0f, 1f, 0f),
+ HitOrgNormalVelocity = 0f,
+ };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ LiveCatchDistanceMin = 5f,
+ LiveCatchDistanceMax = 114f,
+ LiveCatchMinimalBallSpeed = 3f,
+ LiveCatchPerfectTime = 8f,
+ LiveCatchFullTime = 16f,
+ LiveCatchMinimalBounceSpeedMultiplier = 0f,
+ LiveCatchInaccurateBounceSpeedMultiplier = 32f,
+ LiveCatchBaseDampenDistance = 30f,
+ LiveCatchBaseDampen = 0.55f,
+ LiveCatchEosTimeMsec = 100,
+ HasLiveCatchEosTime = true,
+ };
+
+ var outcome = FlipperCollider.LiveCatch(ref ball, in collEvent, in tricks, float3.zero, 5f, 105);
+
+ Assert.That(outcome, Is.EqualTo(LiveCatchOutcome.Caught));
+ Assert.That(ball.Velocity, Is.EqualTo(float3.zero));
+ }
+
+ [Test]
+ public void LiveCatchRequiresValidEosTimestamp()
+ {
+ var ball = CreateCatchBall();
+ var collEvent = CreateCatchEvent();
+ var tricks = CreateLiveCatchData();
+ tricks.HasLiveCatchEosTime = false;
+
+ var outcome = FlipperCollider.LiveCatch(ref ball, in collEvent, in tricks, float3.zero, 5f, 105);
+
+ Assert.That(outcome, Is.EqualTo(LiveCatchOutcome.NotEligible));
+ Assert.That(ball.Velocity, Is.EqualTo(new float3(0f, 4f, 0f)));
+ }
+
+ [Test]
+ public void LiveCatchWindowHandlesUintRollover()
+ {
+ var ball = CreateCatchBall();
+ var collEvent = CreateCatchEvent();
+ var tricks = CreateLiveCatchData();
+ tricks.LiveCatchEosTimeMsec = uint.MaxValue - 4;
+
+ var outcome = FlipperCollider.LiveCatch(ref ball, in collEvent, in tricks, float3.zero, 5f, 3);
+
+ Assert.That(outcome, Is.EqualTo(LiveCatchOutcome.Caught));
+ Assert.That(ball.Velocity, Is.EqualTo(float3.zero));
+ }
+
+ [Test]
+ public void EligibleBaseZoneDoesNotFallThroughWhenNoVelocityChanges()
+ {
+ var ball = CreateCatchBall();
+ ball.Position = new float3(-20f, 0f, 0f);
+ var collEvent = CreateCatchEvent();
+ var tricks = CreateLiveCatchData();
+
+ var outcome = FlipperCollider.LiveCatch(ref ball, in collEvent, in tricks, float3.zero, 5f, 105);
+
+ Assert.That(outcome, Is.EqualTo(LiveCatchOutcome.EligibleNoChange));
+ Assert.That(ball.Velocity, Is.EqualTo(new float3(0f, 4f, 0f)));
+ }
+
+ [Test]
+ public void EligibleBaseZoneDampensBallMovingTowardTip()
+ {
+ var ball = CreateCatchBall();
+ ball.Position = new float3(-20f, 0f, 0f);
+ ball.Velocity = new float3(-2f, 4f, 0f);
+ ball.AngularMomentum = new float3(1f, 2f, 3f);
+ var collEvent = CreateCatchEvent();
+ var tricks = CreateLiveCatchData();
+
+ var outcome = FlipperCollider.LiveCatch(ref ball, in collEvent, in tricks, float3.zero, 5f, 105);
+
+ Assert.That(outcome, Is.EqualTo(LiveCatchOutcome.BaseDampened));
+ Assert.That(ball.Velocity.x, Is.EqualTo(-1.1f).Within(1e-5f));
+ Assert.That(ball.Velocity.y, Is.EqualTo(2.2f).Within(1e-5f));
+ Assert.That(ball.Velocity.z, Is.EqualTo(0f).Within(1e-5f));
+ Assert.That(ball.AngularMomentum.x, Is.EqualTo(0.55f).Within(1e-5f));
+ Assert.That(ball.AngularMomentum.y, Is.EqualTo(1.1f).Within(1e-5f));
+ Assert.That(ball.AngularMomentum.z, Is.EqualTo(1.65f).Within(1e-5f));
+ }
+
+ [TestCase(0f, 1.1f)]
+ [TestCase(3.77f, 0.99f)]
+ [TestCase(6f, 0.99f)]
+ public void EosRubberCurveMatchesVpwProfile(float incomingSpeed, float expectedCor)
+ {
+ Assert.That(FlipperCollider.EosRubberDesiredCor(incomingSpeed), Is.EqualTo(expectedCor).Within(1e-5f));
+ }
+
+ [Test]
+ public void EosRubberCurveInterpolatesAtLowSpeed()
+ {
+ var expected = math.lerp(1.1f, 0.99f, 2f / 3.77f);
+
+ Assert.That(FlipperCollider.EosRubberDesiredCor(2f), Is.EqualTo(expected).Within(1e-5f));
+ }
+
+ [Test]
+ public void HeldEosRubberDampenerScalesLinearVelocityOnly()
+ {
+ var ball = new BallState {
+ Velocity = new float3(1f, -1f, 2f),
+ AngularMomentum = new float3(3f, 4f, 5f),
+ };
+ var movement = new FlipperMovementState { Angle = 0.5f };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ };
+ var postPlayfieldVelocity = ball.Velocity;
+ var incomingSpeed = 3.77f;
+ var expectedCoefficient = 0.99f * incomingSpeed / math.length(postPlayfieldVelocity);
+
+ var applied = FlipperCollider.TryApplyEosRubberDampener(ref ball, in postPlayfieldVelocity,
+ incomingSpeed, true, in movement, in tricks);
+
+ Assert.That(applied, Is.True);
+ Assert.That(ball.Velocity, Is.EqualTo(postPlayfieldVelocity * expectedCoefficient));
+ Assert.That(ball.AngularMomentum, Is.EqualTo(new float3(3f, 4f, 5f)));
+ }
+
+ [TestCase(2f, -1f)]
+ [TestCase(0f, 0f)]
+ [TestCase(0f, -3.75f)]
+ [TestCase(0f, -4f)]
+ public void EosRubberDampenerUsesStrictVelocityWindow(float x, float y)
+ {
+ var ball = new BallState { Velocity = new float3(x, y, 1f) };
+ var movement = new FlipperMovementState { Angle = 0.5f };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ };
+ var postPlayfieldVelocity = ball.Velocity;
+
+ var applied = FlipperCollider.TryApplyEosRubberDampener(ref ball, in postPlayfieldVelocity,
+ 3f, true, in movement, in tricks);
+
+ Assert.That(applied, Is.False);
+ }
+
+ [Test]
+ public void EosRubberDampenerRequiresEnabledHeldFlipperAtEos()
+ {
+ var velocity = new float3(0f, -1f, 1f);
+ var movement = new FlipperMovementState { Angle = 0.5f };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ };
+
+ var disabledBall = new BallState { Velocity = velocity };
+ var disabledTricks = tricks;
+ disabledTricks.UseFlipperLiveCatch = false;
+ Assert.That(FlipperCollider.TryApplyEosRubberDampener(ref disabledBall, in velocity,
+ 3f, true, in movement, in disabledTricks), Is.False);
+
+ var releasedBall = new BallState { Velocity = velocity };
+ Assert.That(FlipperCollider.TryApplyEosRubberDampener(ref releasedBall, in velocity,
+ 3f, false, in movement, in tricks), Is.False);
+
+ var movingBall = new BallState { Velocity = velocity };
+ var awayFromEos = new FlipperMovementState { Angle = math.radians(2f) + tricks.OriginalAngleEnd };
+ Assert.That(FlipperCollider.TryApplyEosRubberDampener(ref movingBall, in velocity,
+ 3f, true, in awayFromEos, in tricks), Is.False);
+ }
+
+ [Test]
+ public void EosRubberDampenerRejectsZeroSpeedWithoutNonFiniteValues()
+ {
+ var ball = new BallState { Velocity = float3.zero };
+ var movement = new FlipperMovementState { Angle = 0.5f };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ };
+ var postPlayfieldVelocity = float3.zero;
+
+ var applied = FlipperCollider.TryApplyEosRubberDampener(ref ball, in postPlayfieldVelocity,
+ 0f, true, in movement, in tricks);
+
+ Assert.That(applied, Is.False);
+ Assert.That(ball.Velocity, Is.EqualTo(float3.zero));
+ }
+
+ [Test]
+ public void EosRubberDampenerUsesPlayfieldVelocityForGateAndCurrentFrameForScaling()
+ {
+ var currentFrameVelocity = new float3(5f, 5f, 1f);
+ var ball = new BallState { Velocity = currentFrameVelocity };
+ var movement = new FlipperMovementState { Angle = 0.5f };
+ var tricks = new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ OriginalAngleEnd = 0.5f,
+ };
+ var postPlayfieldVelocity = new float3(0f, -1f, 0f);
+ var incomingSpeed = 3f;
+ var expectedCoefficient = FlipperCollider.EosRubberDesiredCor(incomingSpeed) * incomingSpeed;
+
+ var applied = FlipperCollider.TryApplyEosRubberDampener(ref ball, in postPlayfieldVelocity,
+ incomingSpeed, true, in movement, in tricks);
+
+ Assert.That(applied, Is.True);
+ Assert.That(ball.Velocity, Is.EqualTo(currentFrameVelocity * expectedCoefficient));
+ }
+
+ private static BallState CreateCatchBall()
+ {
+ return new BallState {
+ Position = new float3(-50f, 0f, 0f),
+ Velocity = new float3(0f, 4f, 0f),
+ };
+ }
+
+ private static CollisionEventData CreateCatchEvent()
+ {
+ return new CollisionEventData {
+ HitNormal = new float3(0f, 1f, 0f),
+ HitOrgNormalVelocity = 0f,
+ };
+ }
+
+ private static FlipperTricksData CreateLiveCatchData()
+ {
+ return new FlipperTricksData {
+ UseFlipperLiveCatch = true,
+ LiveCatchDistanceMin = 5f,
+ LiveCatchDistanceMax = 114f,
+ LiveCatchMinimalBallSpeed = 3f,
+ LiveCatchPerfectTime = 8f,
+ LiveCatchFullTime = 16f,
+ LiveCatchMinimalBounceSpeedMultiplier = 0f,
+ LiveCatchInaccurateBounceSpeedMultiplier = 32f,
+ LiveCatchBaseDampenDistance = 30f,
+ LiveCatchBaseDampen = 0.55f,
+ LiveCatchEosTimeMsec = 100,
+ HasLiveCatchEosTime = true,
+ };
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs.meta
new file mode 100644
index 000000000..96f483cf4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/FlipperPhysicsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f498608e5cfc43a4a847a1261b23c9f4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
index f5f3c71f7..ae7fcb7ee 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
@@ -100,7 +100,8 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
while (enumerator.MoveNext()) {
ref var flipperState = ref enumerator.Current.Value;
FlipperDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref flipperState.Movement,
- ref flipperState.Tricks, in flipperState.Static, hitTime, ref state.EventQueue);
+ ref flipperState.Tricks, in flipperState.Static, flipperState.Solenoid.Value,
+ state.Env.TimeMsec, hitTime, ref state.EventQueue);
}
}
// gates
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
index 953bef7d4..bc4b13dd3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
@@ -56,12 +56,13 @@ private static void TransformBallFromColliderSpace(ref NativeColliders colliders
ball.Transform(matrix);
}
- private static void Collide(ref NativeColliders colliders, ref BallState ball, ref PhysicsState state)
- {
- var colliderId = ball.CollisionEvent.ColliderId;
- var collHeader = state.GetColliderHeader(ref colliders, colliderId);
-
- TransformBallIntoColliderSpace(ref colliders, ref ball, ref state, colliderId);
+ private static void Collide(ref NativeColliders colliders, ref BallState ball, ref PhysicsState state)
+ {
+ var colliderId = ball.CollisionEvent.ColliderId;
+ var collHeader = state.GetColliderHeader(ref colliders, colliderId);
+ var incomingPlayfieldVelocity = ball.Velocity;
+
+ TransformBallIntoColliderSpace(ref colliders, ref ball, ref state, colliderId);
if (CollidesWithItem(ref colliders, ref collHeader, ref ball, ref state)) {
TransformBallFromColliderSpace(ref colliders, ref ball, ref state, colliderId);
@@ -111,13 +112,20 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r
in collHeader, in bumperState.Static, ref state.InsideOfs, bumperState.IsSwitchWiredToCoil);
break;
- case ColliderType.Flipper:
- ref var flipperState = ref state.GetFlipperState(colliderId, ref colliders);
- ref var flipperCollider = ref colliders.Flipper(colliderId);
- flipperCollider.Collide(ref ball, ref ball.CollisionEvent, ref flipperState.Movement,
- ref state.EventQueue, in ball.Id, in flipperState.Tricks, in flipperState.Static,
- in flipperState.Velocity, in flipperState.Hit, state.Env.TimeMsec
- );
+ case ColliderType.Flipper:
+ ref var flipperState = ref state.GetFlipperState(colliderId, ref colliders);
+ ref var flipperCollider = ref colliders.Flipper(colliderId);
+ // Transformed colliders already operate in playfield space. Runtime-local colliders
+ // need their stored matrix to evaluate the VPW velocity gate in that same frame.
+ var colliderToPlayfield = float4x4.identity;
+ if (!colliders.IsTransformed(colliderId)) {
+ colliderToPlayfield = state.GetNonTransformableColliderMatrix(colliderId, ref colliders);
+ }
+ flipperCollider.Collide(ref ball, ref ball.CollisionEvent, ref flipperState.Movement,
+ ref state.EventQueue, in ball.Id, in flipperState.Tricks, in flipperState.Static,
+ in flipperState.Velocity, in flipperState.Hit, flipperState.Solenoid.Value,
+ in incomingPlayfieldVelocity, in colliderToPlayfield, state.Env.TimeMsec
+ );
break;
case ColliderType.Gate:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
index 53454ede6..98aedcf8d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
@@ -23,9 +23,17 @@
using VisualPinball.Engine.Game;
using Logger = NLog.Logger;
-namespace VisualPinball.Unity
-{
- ///
+namespace VisualPinball.Unity
+{
+ internal enum LiveCatchOutcome
+ {
+ NotEligible,
+ EligibleNoChange,
+ BaseDampened,
+ Caught,
+ }
+
+ ///
/// Our custom flipper collider.
///
///
@@ -734,9 +742,11 @@ private void GetRelativeVelocity(in float3 normal, in BallState ball, in Flipper
#region LiveCatch
- public static void LiveCatch(ref BallState ball, ref CollisionEventData collEvent, in FlipperTricksData tricks, float3 flipperPos, in FlipperStaticData matData, uint msec) {
- if (!tricks.UseFlipperLiveCatch)
- return;
+ internal static LiveCatchOutcome LiveCatch(ref BallState ball, in CollisionEventData collEvent, in FlipperTricksData tricks,
+ float3 flipperPos, float impactSpeed, uint msec) {
+ if (!tricks.UseFlipperLiveCatch) {
+ return LiveCatchOutcome.NotEligible;
+ }
// Vector from position of the flipper ball to ball
var flipperToBall = ball.Position - flipperPos;
@@ -744,24 +754,27 @@ public static void LiveCatch(ref BallState ball, ref CollisionEventData collEven
var ballPosition = math.dot(hitTangent, flipperToBall);
var liveDist = math.abs(ballPosition);
//Logger.Info("BallPosition = {0}", liveDist);
- if (liveDist >= tricks.LiveCatchDistanceMax) {
- //Logger.Info("BallPosition = {0} -> no calculation", ballPosition);
- return;
- }
- if (liveDist <= tricks.LiveCatchDistanceMin) {
- //Logger.Info("BallPosition = {0} -> no calculation", ballPosition);
- return;
- }
-
- var impactSpeed = math.max(math.dot(collEvent.HitNormal, ball.Velocity) * -1f, -collEvent.HitOrgNormalVelocity);
- if (impactSpeed <= tricks.LiveCatchMinimalBallSpeed) {
- return;
- }
-
- var catchTime = (float)(msec - tricks.FlipperAngleEndTime * 1000);
- if (catchTime > tricks.LiveCatchFullTime) {
- return;
- }
+ if (liveDist >= tricks.LiveCatchDistanceMax) {
+ //Logger.Info("BallPosition = {0} -> no calculation", ballPosition);
+ return LiveCatchOutcome.NotEligible;
+ }
+ if (liveDist <= tricks.LiveCatchDistanceMin) {
+ //Logger.Info("BallPosition = {0} -> no calculation", ballPosition);
+ return LiveCatchOutcome.NotEligible;
+ }
+
+ if (impactSpeed <= tricks.LiveCatchMinimalBallSpeed) {
+ return LiveCatchOutcome.NotEligible;
+ }
+
+ if (!tricks.HasLiveCatchEosTime) {
+ return LiveCatchOutcome.NotEligible;
+ }
+
+ var catchTime = unchecked(msec - tricks.LiveCatchEosTimeMsec);
+ if (catchTime > tricks.LiveCatchFullTime) {
+ return LiveCatchOutcome.NotEligible;
+ }
var flipperAxis = hitTangent;
if (ballPosition < 0f) {
@@ -770,13 +783,14 @@ public static void LiveCatch(ref BallState ball, ref CollisionEventData collEven
var tangentSpeed = math.dot(ball.Velocity, flipperAxis);
var movingTowardTip = tangentSpeed > 0f;
- if (liveDist <= tricks.LiveCatchBaseDampenDistance) {
- if (movingTowardTip && liveDist < tricks.LiveCatchBaseDampenDistance) {
- ball.Velocity *= tricks.LiveCatchBaseDampen;
- ball.AngularMomentum *= tricks.LiveCatchBaseDampen;
- }
- return;
- }
+ if (liveDist <= tricks.LiveCatchBaseDampenDistance) {
+ if (movingTowardTip && liveDist < tricks.LiveCatchBaseDampenDistance) {
+ ball.Velocity *= tricks.LiveCatchBaseDampen;
+ ball.AngularMomentum *= tricks.LiveCatchBaseDampen;
+ return LiveCatchOutcome.BaseDampened;
+ }
+ return LiveCatchOutcome.EligibleNoChange;
+ }
// Modern VPW live catch zeroes the flipper-face rebound, but keeps VPE's orientation-independent normal.
ball.Velocity -= math.dot(collEvent.HitNormal, ball.Velocity) * collEvent.HitNormal;
@@ -790,21 +804,74 @@ public static void LiveCatch(ref BallState ball, ref CollisionEventData collEven
}
// VPX applies this as table Y velocity; VPE applies the same speed away from the flipper face.
- ball.Velocity += collEvent.HitNormal * liveCatchBounceSpeed;
- ball.AngularMomentum = float3.zero;
- }
+ ball.Velocity += collEvent.HitNormal * liveCatchBounceSpeed;
+ ball.AngularMomentum = float3.zero;
+ return LiveCatchOutcome.Caught;
+ }
+
+ internal static bool TryApplyEosRubberDampener(ref BallState ball, in float3 postPlayfieldVelocity,
+ float incomingPlayfieldSpeed, bool solenoidEnabled, in FlipperMovementState movementState,
+ in FlipperTricksData tricks)
+ {
+ const float maxLateralSpeed = 2f;
+ const float maxNegativeYSpeed = -3.75f;
+ var endAngleTolerance = math.radians(1f);
+
+ if (!tricks.UseFlipperLiveCatch || !solenoidEnabled
+ || math.abs(movementState.Angle - tricks.OriginalAngleEnd) > endAngleTolerance) {
+ return false;
+ }
+
+ if (math.abs(postPlayfieldVelocity.x) >= maxLateralSpeed
+ || postPlayfieldVelocity.y >= 0f
+ || postPlayfieldVelocity.y <= maxNegativeYSpeed) {
+ return false;
+ }
+
+ var postPlayfieldSpeed = math.length(postPlayfieldVelocity);
+ if (incomingPlayfieldSpeed <= PhysicsConstants.Precision
+ || postPlayfieldSpeed <= PhysicsConstants.Precision) {
+ return false;
+ }
+
+ var desiredCor = EosRubberDesiredCor(incomingPlayfieldSpeed);
+ var coefficient = desiredCor * incomingPlayfieldSpeed / postPlayfieldSpeed;
+ if (float.IsNaN(coefficient) || float.IsInfinity(coefficient)) {
+ return false;
+ }
+
+ ball.Velocity *= coefficient;
+ return true;
+ }
+
+ internal static float EosRubberDesiredCor(float incomingPlayfieldSpeed)
+ {
+ const float lowSpeedCor = 1.1f;
+ const float plateauSpeed = 3.77f;
+ const float plateauCor = 0.99f;
+
+ if (incomingPlayfieldSpeed <= 0f) {
+ return lowSpeedCor;
+ }
+ if (incomingPlayfieldSpeed >= plateauSpeed) {
+ return plateauCor;
+ }
+ return math.lerp(lowSpeedCor, plateauCor, incomingPlayfieldSpeed / plateauSpeed);
+ }
#endregion
#region Collision
- public void Collide(ref BallState ball, ref CollisionEventData collEvent, ref FlipperMovementState movementState,
- ref NativeQueue.ParallelWriter events, in int ballId, in FlipperTricksData tricks, in FlipperStaticData matData,
- in FlipperVelocityData velData, in FlipperHitData hitData, uint timeMsec)
+ public void Collide(ref BallState ball, ref CollisionEventData collEvent, ref FlipperMovementState movementState,
+ ref NativeQueue.ParallelWriter events, in int ballId, in FlipperTricksData tricks, in FlipperStaticData matData,
+ in FlipperVelocityData velData, in FlipperHitData hitData, bool solenoidEnabled,
+ in float3 incomingPlayfieldVelocity, in float4x4 colliderToPlayfield, uint timeMsec)
{
var normal = collEvent.HitNormal;
- GetRelativeVelocity(normal, ball, movementState, out var vRel, out var rB, out var rF);
-
- var bnv = math.dot(normal, vRel); // relative normal velocity
+ GetRelativeVelocity(normal, ball, movementState, out var vRel, out var rB, out var rF);
+
+ var bnv = math.dot(normal, vRel); // relative normal velocity
+ var ballImpactSpeed = math.max(0f, -math.dot(normal, ball.Velocity));
if (bnv >= -PhysicsConstants.LowNormVel) {
// nearly receding ... make sure of conditions
@@ -930,7 +997,13 @@ public void Collide(ref BallState ball, ref CollisionEventData collEvent, ref Fl
movementState.ApplyImpulse(-jt * crossF, matData.Inertia);
}
- LiveCatch(ref ball, ref collEvent, in tricks, new float3(_hitCircleBase.Center, ball.Position.z), in matData, timeMsec);
+ var liveCatchOutcome = LiveCatch(ref ball, in collEvent, in tricks,
+ new float3(_hitCircleBase.Center, ball.Position.z), ballImpactSpeed, timeMsec);
+ if (liveCatchOutcome == LiveCatchOutcome.NotEligible) {
+ var postPlayfieldVelocity = colliderToPlayfield.MultiplyVector(ball.Velocity);
+ TryApplyEosRubberDampener(ref ball, in postPlayfieldVelocity, math.length(incomingPlayfieldVelocity),
+ solenoidEnabled, in movementState, in tricks);
+ }
// event
if (bnv < -0.25f && timeMsec - movementState.LastHitTime > 250) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs
index cc99ea8c5..09580df40 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs
@@ -168,8 +168,8 @@ public override bool PhysicsOverwrite {
/// If set, apply Live Catch (nFozzy/RothBauerW)
///
- [Tooltip("Enables modern VPW live-catch behavior for balls arriving just after the flipper reaches end-of-stroke.")]
- public bool useFlipperLiveCatch = false;
+ [Tooltip("Enables modern VPW live catches, base-zone dampening, and held-EOS rubber correction.")]
+ public bool useFlipperLiveCatch = false;
[Min(0f)]
[Tooltip("Closest distance from the flipper base where live catch can apply, in VP units. Modern VPW scripts usually use 5.")]
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperDisplacementPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperDisplacementPhysics.cs
index 83d5ad1bc..1615992b9 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperDisplacementPhysics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperDisplacementPhysics.cs
@@ -20,15 +20,13 @@
namespace VisualPinball.Unity
{
- internal static class FlipperDisplacementPhysics
- {
- internal static void UpdateDisplacement(int itemId, ref FlipperMovementState movementState, ref FlipperTricksData tricks, in FlipperStaticData staticState,
- float dTime, ref NativeQueue.ParallelWriter events)
- {
- //var dTime = _simulateCycleSystemGroup.HitTime;
- var currentTime = 0;// todo SystemAPI.Time.ElapsedTime;
-
- movementState.Angle += movementState.AngleSpeed * dTime; // move flipper angle
+ internal static class FlipperDisplacementPhysics
+ {
+ internal static void UpdateDisplacement(int itemId, ref FlipperMovementState movementState, ref FlipperTricksData tricks, in FlipperStaticData staticState,
+ bool solenoidEnabled, uint currentTimeMsec, float dTime, ref NativeQueue.ParallelWriter events)
+ {
+ var previousAngle = movementState.Angle;
+ movementState.Angle += movementState.AngleSpeed * dTime; // move flipper angle
var angleMin = math.min(staticState.AngleStart, tricks.AngleEnd);
var angleMax = math.max(staticState.AngleStart, tricks.AngleEnd);
@@ -37,23 +35,33 @@ internal static void UpdateDisplacement(int itemId, ref FlipperMovementState mov
movementState.Angle = angleMax;
}
- if (movementState.Angle < angleMin) {
- movementState.Angle = angleMin;
- }
-
- if (math.abs(movementState.AngleSpeed) < 0.0005f) {
+ if (movementState.Angle < angleMin) {
+ movementState.Angle = angleMin;
+ }
+
+ if (tricks.UseFlipperLiveCatch && solenoidEnabled && !tricks.HasLiveCatchEosTime) {
+ const float angleTolerance = 1e-4f;
+ var endAngle = tricks.AngleEnd;
+ var rotatesPositive = endAngle >= staticState.AngleStart;
+ var crossedEnd = rotatesPositive
+ ? previousAngle < endAngle - angleTolerance && movementState.Angle >= endAngle - angleTolerance
+ : previousAngle > endAngle + angleTolerance && movementState.Angle <= endAngle + angleTolerance;
+ // A solenoid edge invalidates the previous window. Only a fresh upstroke may re-arm it;
+ // otherwise a quick release/re-press while still at EOS would create a new catch window.
+ if (crossedEnd) {
+ tricks.LiveCatchEosTimeMsec = currentTimeMsec;
+ tricks.HasLiveCatchEosTime = true;
+ }
+ }
+
+ if (math.abs(movementState.AngleSpeed) < 0.0005f) {
// avoids "jumping balls" when two or more balls held on flipper (and more other balls are in play) //!! make dependent on physics update rate
return;
}
var handleEvent = false;
- // ReSharper disable once CompareOfFloatsByEqualityOperator
- if (movementState.Angle == tricks.AngleEnd) {
- tricks.FlipperAngleEndTime = currentTime;
- }
-
- if (movementState.Angle >= angleMax) {
+ if (movementState.Angle >= angleMax) {
// hit stop?
if (movementState.AngleSpeed > 0) {
handleEvent = true;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs
index 51d444bc5..16fdebb63 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs
@@ -35,8 +35,9 @@ internal struct FlipperTricksData
public bool WasInContact;
- // time used for live Catch
- public double FlipperAngleEndTime;
+ // Time used for live catch. The timestamp is latched once per upstroke.
+ public uint LiveCatchEosTimeMsec;
+ public bool HasLiveCatchEosTime;
// externals
// Flipper Tricks
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs
index 97e94b7b3..159779f58 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperVelocityPhysics.cs
@@ -32,10 +32,14 @@ internal static void UpdateVelocities(ref FlipperState state)
ref var data = ref state.Static;
var angleMin = math.min(data.AngleStart, tricks.AngleEnd);
- var angleMax = math.max(data.AngleStart, tricks.AngleEnd);
- var minIsStart = angleMin == data.AngleStart; // Usually true for the right Flipper
-
- var desiredTorque = data.Strength;
+ var angleMax = math.max(data.AngleStart, tricks.AngleEnd);
+ var minIsStart = angleMin == data.AngleStart; // Usually true for the right Flipper
+ var solenoidChanged = solenoid.Value != tricks.lastSolState;
+ if (solenoidChanged) {
+ tricks.HasLiveCatchEosTime = false;
+ }
+
+ var desiredTorque = data.Strength;
if (!solenoid.Value)
{
// True solState = button pressed, false = released
@@ -44,7 +48,7 @@ internal static void UpdateVelocities(ref FlipperState state)
if (tricks.UseFlipperTricksPhysics) {
// check if solenoid was just activated or deactivated
- if (solenoid.Value != tricks.lastSolState)
+ if (solenoidChanged)
{
// check if solenoid was just activated or deactivated for Flippertricks
// Flippertricks case 1 and 2 are always before case 3, 4 and 5.
@@ -171,9 +175,10 @@ internal static void UpdateVelocities(ref FlipperState state)
tricks.WasInContact = vState.IsInContact;
- // check if solenoid was just activated or deactivated
- tricks.lastSolState = solenoid.Value;
- }
- }
+ }
+
+ // Live catch also needs reliable edge tracking when Flipper Tricks is disabled.
+ tricks.lastSolState = solenoid.Value;
+ }
}
}