diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs new file mode 100644 index 000000000..4200cace7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs @@ -0,0 +1,87 @@ +// 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; +using UnityEngine; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + /// + /// A ball that is not hit-tested must not collide with anything. The default collision + /// event is a hit with collider 0 at time 0, and a ball held in a kicker skips the hit + /// tests, so a new ball that got captured before its first physics step collided with + /// whatever collider 0 was. On a table where that was the playfield floor, the ball was + /// pushed from the trough exit up to playfield level and kicked from there. + /// + public class StaleCollisionEventTests + { + [Test] + public void NewBallStartsWithoutPendingCollision() + { + var go = new GameObject("Ball"); + try { + var ball = go.AddComponent(); + var state = ball.CreateState(); + + Assert.That(state.CollisionEvent.HasCollider(), Is.False); + } finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void FrozenBallIsLeftAloneByTheCollisionPhase() + { + var nonTransformable = new NativeParallelHashMap(1, Allocator.Persistent); + var references = new ColliderReference(ref nonTransformable, Allocator.Persistent); + try { + // collider 0 is a floor at z = 0, the way a table's playfield plane is + references.Add(new PlaneCollider(new float3(0f, 0f, 1f), 0f, new ColliderInfo { ItemId = 1, ItemType = ItemType.Playfield })); + using var colliders = new NativeColliders(ref references, Allocator.Persistent); + using var balls = new NativeParallelHashMap(2, Allocator.Persistent); + var state = new PhysicsState { Colliders = colliders, Balls = balls }; + + // a ball held below the playfield, with the collision event a new ball comes with + var ball = new BallState { + Id = 7, Radius = 25f, Mass = 1f, IsFrozen = true, + Position = new float3(769f, 1868f, -83f), + }; + Assert.That(ball.CollisionEvent.ColliderId, Is.EqualTo(0), "the default event points at collider 0"); + + PhysicsStaticCollision.Collide(PhysicsConstants.PhysFactor, ref ball, ref state); + Assert.That(ball.Position.z, Is.EqualTo(-83f).Within(1e-4f), "a held ball stays where the kicker put it"); + + // a stale ball-ball event: another ball dropping onto the held one + var other = new BallState { Id = 8, Radius = 25f, Mass = 1f, Position = new float3(769f, 1868f, -33f), Velocity = new float3(0f, 0f, -10f) }; + balls.Add(other.Id, other); + ball.CollisionEvent.SetBallItem(other.Id); + ball.CollisionEvent.HitTime = 0f; + + PhysicsDynamicCollision.Collide(PhysicsConstants.PhysFactor, ref ball, ref state); + + Assert.That(math.length(ball.Velocity), Is.EqualTo(0f), "the held ball is not bounced"); + Assert.That(balls[other.Id].Velocity.z, Is.EqualTo(-10f).Within(1e-4f), "nor is the other ball"); + } finally { + references.Dispose(); + nonTransformable.Dispose(); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta new file mode 100644 index 000000000..6422a5704 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/StaleCollisionEventTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: accfa0b6c35f4c588076dc1ccea95e5f +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 8d5c6ead7..f308c45ec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -268,6 +268,11 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov continue; } ref var ball = ref state.Balls.GetValueByRef(contact.BallId); + if (ball.IsFrozen) { + // captured by a kicker in this very collision phase: the contact was found + // where the ball was before, its recovery would move it out of the kicker + continue; + } if (contact.CollEvent.IsKinematic) { ContactPhysics.Update(ref contact, ref ball, ref state, ref state.KinematicColliders, hitTime); } else { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs index 553e4fab6..afb29b06c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicCollision.cs @@ -25,6 +25,11 @@ internal static class PhysicsDynamicCollision internal static void Collide(float hitTime, ref BallState ball, ref PhysicsState state) { + // a frozen ball is not hit-tested, so whatever event it carries is not from this cycle + if (ball.IsFrozen) { + return; + } + // pick "other" ball ref var collEvent = ref ball.CollisionEvent; ref var otherId = ref collEvent.BallId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index eece81766..04b0c7dc0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -26,6 +26,11 @@ internal static class PhysicsStaticCollision { internal static void Collide(float hitTime, ref BallState ball, ref PhysicsState state) { + // a frozen ball is not hit-tested, so whatever event it carries is not from this cycle + if (ball.IsFrozen) { + return; + } + // find balls with hit objects and minimum time if (ball.CollisionEvent.ColliderId < 0 || ball.CollisionEvent.HitTime > hitTime) { return; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs index 015af599b..4862248bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs @@ -32,7 +32,13 @@ public struct CollisionEventData public int ColliderId; public bool IsKinematic; public int BallId; - + + /// + /// No pending collision. The default value of this struct is a hit with collider 0 at + /// time 0, so every new ball has to start out with this instead. + /// + public static CollisionEventData None => new() { ColliderId = -1 }; + public void SetCollider(int colliderId, bool isKinematic) { // it's either collider id (for static colliders) or ball id (for dynamic colliders) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs index 9ab722862..10ae91b07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs @@ -48,7 +48,8 @@ internal BallState CreateState() BallOrientation = float3x3.identity, BallOrientationForUnity = float3x3.identity, RingCounterOldPos = 0, - AngularMomentum = float3.zero + AngularMomentum = float3.zero, + CollisionEvent = CollisionEventData.None }; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs index d17c4fac5..cad066b40 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs @@ -102,6 +102,12 @@ private void CreateCapturedBall(GameObject ballPrefab, float radius, float mass) var kickerId = ItemId; var kickerName = MainComponent.name; // resolved here, the callback runs on the simulation thread + // where the ball was spawned and what the kicker's transforms say right now. A created + // ball that leaves from the wrong height is otherwise impossible to diagnose afterwards. + var creation = MainComponent.GetBallCreationPosition(); + Logger.Info($"Kicker \"{kickerName}\": created ball {ballId} at ({creation.X:F1}, {creation.Y:F1}, {creation.Z:F1}), " + + $"kicker local {MainComponent.Position:F1}, in playfield {MainComponent.PositionInPlayfield:F1}, world {MainComponent.transform.position:F4}."); + PhysicsEngine.MutateState((ref PhysicsState state) => { if (!state.Balls.ContainsKey(ballId)) { Logger.Warn($"Kicker \"{kickerName}\": ball {ballId} was created but is unknown to the physics state, it cannot be captured."); @@ -130,6 +136,8 @@ private void CreateCapturedBall(GameObject ballPrefab, float radius, float mass) // kick launches whatever the kicker holds from wherever it is, so say so. if (!kickerState.Static.FallThrough && kickerState.Collision.BallId != ballId) { Logger.Warn($"Kicker \"{kickerName}\": created ball {ballId} was not captured, the kicker holds ball {kickerState.Collision.BallId}. The new ball is at {ball.Position}."); + } else { + Logger.Info($"Kicker \"{kickerName}\": ball {ballId} captured at {ball.Position}, kicker center {kickerState.Static.Center}, z {kickerState.Static.ZLow}."); } }); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs index 352604925..4f9facd3f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs @@ -25,6 +25,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using NLog; using Unity.Collections; using Unity.Mathematics; using UnityEngine; @@ -34,6 +35,7 @@ using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Kicker; using VisualPinball.Engine.VPT.Table; +using Logger = NLog.Logger; #if UNITY_EDITOR using UnityEditor; @@ -48,6 +50,8 @@ public class KickerComponent : MainRenderableComponent, ICoilDeviceComponent, ITriggerComponent, IBallCreationPosition, IRotatableComponent, ISerializationCallbackReceiver, IPackable { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + #region Data public Vector3 Position { @@ -167,7 +171,8 @@ public float RotateZ { public float2 RotatedPosition { get => new(Position.x, Position.y); set { - Position = new Vector2(value.x, value.y); + // keep the height: a kicker rotated by a mech must not drop to playfield level + Position = new Vector3(value.x, value.y, Position.z); UpdateTransforms(); } } @@ -307,6 +312,8 @@ internal KickerState CreateState() ? new ColliderMeshData(Array.Empty(), 0, float3.zero, Allocator.Persistent) : new ColliderMeshData(KickerHitMesh.Vertices, Radius, position, Allocator.Persistent); + Logger.Info($"Kicker \"{name}\": capture center ({position.x:F1}, {position.y:F1}), z {position.z:F1}, local {Position:F1}."); + return new KickerState( staticData, new KickerCollisionState(),