Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

using NUnit.Framework;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using VisualPinball.Engine.Common;
using VisualPinball.Engine.VPT;

namespace VisualPinball.Unity.Test
{
/// <summary>
/// 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.
/// </summary>
public class StaleCollisionEventTests
{
[Test]
public void NewBallStartsWithoutPendingCollision()
{
var go = new GameObject("Ball");
try {
var ball = go.AddComponent<BallComponent>();
var state = ball.CreateState();

Assert.That(state.CollisionEvent.HasCollider(), Is.False);
} finally {
Object.DestroyImmediate(go);
}
}

[Test]
public void FrozenBallIsLeftAloneByTheCollisionPhase()
{
var nonTransformable = new NativeParallelHashMap<int, float4x4>(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<int, BallState>(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();
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet<int> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ public struct CollisionEventData
public int ColliderId;
public bool IsKinematic;
public int BallId;


/// <summary>
/// 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.
/// </summary>
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ internal BallState CreateState()
BallOrientation = float3x3.identity,
BallOrientationForUnity = float3x3.identity,
RingCounterOldPos = 0,
AngularMomentum = float3.zero
AngularMomentum = float3.zero,
CollisionEvent = CollisionEventData.None
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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}.");
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NLog;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
Expand All @@ -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;
Expand All @@ -48,6 +50,8 @@ public class KickerComponent : MainRenderableComponent<KickerData>,
ICoilDeviceComponent, ITriggerComponent, IBallCreationPosition,
IRotatableComponent, ISerializationCallbackReceiver, IPackable
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();

#region Data

public Vector3 Position {
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -307,6 +312,8 @@ internal KickerState CreateState()
? new ColliderMeshData(Array.Empty<Vertex3DNoTex2>(), 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(),
Expand Down
Loading