diff --git a/VisualPinball.Engine.Test/Test/Fixtures.cs b/VisualPinball.Engine.Test/Test/Fixtures.cs
index 93d80fead..fe3c6946c 100644
--- a/VisualPinball.Engine.Test/Test/Fixtures.cs
+++ b/VisualPinball.Engine.Test/Test/Fixtures.cs
@@ -105,7 +105,21 @@ public static class PathHelper
{
public static string GetFixturePath(string filename)
{
- return Path.GetFullPath(Path.Combine(GetTestPath(), "Fixtures~", filename));
+ var expectedPath = Path.GetFullPath(Path.Combine(GetTestPath(), "Fixtures~", filename));
+ if (File.Exists(expectedPath)) {
+ return expectedPath;
+ }
+
+ var searchDir = new DirectoryInfo(Path.GetDirectoryName(new System.Uri(Assembly.GetExecutingAssembly().CodeBase).LocalPath));
+ while (searchDir != null) {
+ var fixturePath = Path.Combine(searchDir.FullName, "VisualPinball.Engine.Test", "Fixtures~", filename);
+ if (File.Exists(fixturePath)) {
+ return fixturePath;
+ }
+ searchDir = searchDir.Parent;
+ }
+
+ return expectedPath;
}
private static string GetTestPath()
diff --git a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs
new file mode 100644
index 000000000..efd810f7f
--- /dev/null
+++ b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs
@@ -0,0 +1,129 @@
+// 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.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+using VisualPinball.Engine.VPT;
+using VisualPinball.Engine.VPT.Plunger;
+
+namespace VisualPinball.Engine.Test.VPT.Plunger
+{
+ public class PlungerMeshTests
+ {
+ [Test]
+ public void ShouldCloseModernRodTip()
+ {
+ var mesh = new PlungerMeshGenerator(new PlungerData()).GetMesh(0.0f, PlungerMeshGenerator.Rod);
+ var maxZ = mesh.Vertices.Max(v => v.Z);
+
+ FindBoundaryEdges(mesh)
+ .Where(e => System.Math.Abs(mesh.Vertices[e.a].Z - maxZ) < 0.0001f && System.Math.Abs(mesh.Vertices[e.b].Z - maxZ) < 0.0001f)
+ .Should().BeEmpty();
+ }
+
+ [Test]
+ public void ShouldKeepCustomRodTipCapCoplanarWithOffsetFirstTipPoint()
+ {
+ var data = new PlungerData {
+ Type = PlungerType.PlungerTypeCustom,
+ TipShape = "5 .34; 10 .5"
+ };
+
+ var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Rod);
+ var maxZ = mesh.Vertices.Max(v => v.Z);
+
+ mesh.Vertices.Count(v => System.Math.Abs(v.Z - maxZ) < 0.0001f).Should().BeGreaterThan(1);
+ }
+
+ [Test]
+ public void ShouldBuildEmptySpringForZeroLoops()
+ {
+ var data = new PlungerData {
+ Type = PlungerType.PlungerTypeCustom,
+ SpringLoops = 0.0f,
+ SpringEndLoops = 0.0f
+ };
+
+ var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Spring);
+
+ mesh.Vertices.Should().BeEmpty();
+ mesh.Indices.Should().BeEmpty();
+ }
+
+ [Test]
+ public void ShouldBuildEmptySpringForNegativeLoops()
+ {
+ var data = new PlungerData {
+ Type = PlungerType.PlungerTypeCustom,
+ SpringLoops = -1.0f,
+ SpringEndLoops = 0.0f
+ };
+
+ var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Spring);
+
+ mesh.Vertices.Should().BeEmpty();
+ mesh.Indices.Should().BeEmpty();
+ }
+
+ [TestCase(PlungerMeshGenerator.Rod)]
+ [TestCase(PlungerMeshGenerator.Spring)]
+ [TestCase(PlungerMeshGenerator.Flat)]
+ public void ShouldBuildLocalMeshIndependentOfZAdjust(string meshId)
+ {
+ var baseMesh = new PlungerMeshGenerator(CreateData(meshId, 0.0f)).GetLocalMesh(meshId);
+ var elevatedMesh = new PlungerMeshGenerator(CreateData(meshId, 17.0f)).GetLocalMesh(meshId);
+
+ elevatedMesh.Vertices.Should().HaveCount(baseMesh.Vertices.Length);
+ for (var i = 0; i < baseMesh.Vertices.Length; i++) {
+ elevatedMesh.Vertices[i].X.Should().BeApproximately(baseMesh.Vertices[i].X, 0.0001f);
+ elevatedMesh.Vertices[i].Y.Should().BeApproximately(baseMesh.Vertices[i].Y, 0.0001f);
+ elevatedMesh.Vertices[i].Z.Should().BeApproximately(baseMesh.Vertices[i].Z, 0.0001f);
+ }
+ }
+
+ private static IEnumerable<(int a, int b)> FindBoundaryEdges(Mesh mesh)
+ {
+ var edgeCounts = new Dictionary<(int a, int b), int>();
+ for (var i = 0; i < mesh.Indices.Length; i += 3) {
+ AddEdge(mesh.Indices[i], mesh.Indices[i + 1], edgeCounts);
+ AddEdge(mesh.Indices[i + 1], mesh.Indices[i + 2], edgeCounts);
+ AddEdge(mesh.Indices[i + 2], mesh.Indices[i], edgeCounts);
+ }
+ return edgeCounts.Where(e => e.Value == 1).Select(e => e.Key);
+ }
+
+ private static void AddEdge(int a, int b, IDictionary<(int a, int b), int> edgeCounts)
+ {
+ var edge = a < b ? (a, b) : (b, a);
+ edgeCounts.TryGetValue(edge, out var count);
+ edgeCounts[edge] = count + 1;
+ }
+
+ private static PlungerData CreateData(string meshId, float zAdjust)
+ {
+ return new PlungerData {
+ Type = meshId switch {
+ PlungerMeshGenerator.Flat => PlungerType.PlungerTypeFlat,
+ PlungerMeshGenerator.Spring => PlungerType.PlungerTypeCustom,
+ _ => PlungerType.PlungerTypeModern
+ },
+ ZAdjust = zAdjust
+ };
+ }
+ }
+}
diff --git a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs
index 8b0d254bd..2125d0783 100644
--- a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs
+++ b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs
@@ -70,12 +70,23 @@ public PlungerMeshGenerator(PlungerData data)
Init(0);
}
- public Mesh GetMesh(float height, string id)
- {
- Init(height);
- switch (id) {
- case Flat:
- return BuildFlatMesh();
+ public Mesh GetMesh(float height, string id)
+ {
+ Init(height);
+ return BuildMesh(id);
+ }
+
+ public Mesh GetLocalMesh(string id)
+ {
+ Init(0.0f, false);
+ return BuildMesh(id);
+ }
+
+ private Mesh BuildMesh(string id)
+ {
+ switch (id) {
+ case Flat:
+ return BuildFlatMesh();
case Rod:
CalculateArraySizes();
return BuildRodMesh();
@@ -116,8 +127,8 @@ public PbrMaterial GetMaterial(Table.Table table)
}
- private void Init(float height)
- {
+ private void Init(float height, bool includeZAdjust = true)
+ {
var stroke = _data.Stroke;
_beginY = 0;
_endY = -stroke;
@@ -141,10 +152,10 @@ private void Init(float height)
// figure the width in relative units (0..1) of each cell
_cellWid = 1.0f / _srcCells;
- _zHeight = height + _data.ZAdjust;
- _zScale = 1f;
- _desc = GetPlungerDesc();
- }
+ _zHeight = height + (includeZAdjust ? _data.ZAdjust : 0.0f);
+ _zScale = 1f;
+ _desc = GetPlungerDesc();
+ }
private PlungerDesc GetPlungerDesc()
{
@@ -175,7 +186,7 @@ private void CalculateArraySizes()
// spirals, where each spiral has 'springLoops' loops
// times 'circlePoints' vertices.
_latheVts = _lathePoints * _circlePoints;
- _springVts = (int)((_springLoops + _springEndLoops) * _circlePoints) * 3;
+ _springVts = System.Math.Max(0, (int)((_springLoops + _springEndLoops) * _circlePoints) * 3);
// For the lathed section, we need two triangles == 6
// indices for every point on every lathe circle past
@@ -193,13 +204,13 @@ private void CalculateArraySizes()
// of sets. 12*vts/3 = 4*vts.
//
// The spring only applies to the custom plunger.
- _springIndices = 0;
- if (_data.Type == PlungerType.PlungerTypeCustom) {
- _springIndices = 4 * _springVts - 12;
- if (_springVts < 0) {
- _springIndices = 0;
- }
- }
+ _springIndices = 0;
+ if (_data.Type == PlungerType.PlungerTypeCustom) {
+ _springIndices = 4 * _springVts - 12;
+ if (_springVts <= 3) {
+ _springIndices = 0;
+ }
+ }
}
///
@@ -297,12 +308,12 @@ public Vertex3DNoTex2[] BuildFlatVertices(int frame)
/// cylinder.
///
///
- private Mesh BuildRodMesh()
- {
- var mesh = new Mesh("rod") {
- Vertices = BuildRodVertices(0),
- Indices = new int[_latheIndices]
- };
+ private Mesh BuildRodMesh()
+ {
+ var mesh = new Mesh("rod") {
+ Vertices = BuildRodVertices(0),
+ Indices = new int[_latheIndices + 3 * _circlePoints]
+ };
// set up the vertex list for the lathe circles
var k = 0;
@@ -314,13 +325,25 @@ private Mesh BuildRodMesh()
mesh.Indices[k++] = (m + offset + 1 + _lathePoints) % _latheVts;
mesh.Indices[k++] = (m + offset + 1) % _latheVts;
- mesh.Indices[k++] = (m + offset) % _latheVts;
- }
- }
-
- mesh.AnimationFrames = new List(1);
- mesh.AnimationDefaultPosition = DefaultPosition;
- var vertices = BuildRodVertices(NumFrames);
+ mesh.Indices[k++] = (m + offset) % _latheVts;
+ }
+ }
+
+ // Close the front tip. The side faces connect the lathe rings but leave
+ // the first ring open, so add a center vertex and fan triangles.
+ var tipCenter = _latheVts;
+ for (var l = 0; l < _circlePoints; l++) {
+ var current = l * _lathePoints;
+ var next = ((l + 1) % _circlePoints) * _lathePoints;
+
+ mesh.Indices[k++] = tipCenter;
+ mesh.Indices[k++] = next;
+ mesh.Indices[k++] = current;
+ }
+
+ mesh.AnimationFrames = new List(1);
+ mesh.AnimationDefaultPosition = DefaultPosition;
+ var vertices = BuildRodVertices(NumFrames);
mesh.AnimationFrames.Add(vertices.Select(v => new Mesh.VertData(v.X, v.Y, v.Z, v.Nx, v.Ny, v.Nz)).ToArray());
return mesh;
@@ -328,11 +351,11 @@ private Mesh BuildRodMesh()
public Vertex3DNoTex2[] BuildRodVertices(int frame)
{
- if (_lathePoints == 0) {
- CalculateArraySizes();
- }
- var vertices = new Vertex3DNoTex2[_latheVts];
- var yTip = _beginY + _dyPerFrame * frame;
+ if (_lathePoints == 0) {
+ CalculateArraySizes();
+ }
+ var vertices = new Vertex3DNoTex2[_latheVts + 1];
+ var yTip = _beginY + _dyPerFrame * frame;
var tu = 0.51f;
var stepU = 1.0f / _circlePoints;
@@ -385,11 +408,22 @@ public Vertex3DNoTex2[] BuildRodVertices(int frame)
Tu = tu,
Tv = tv
};
- }
- }
-
- return vertices;
- }
+ }
+ }
+
+ vertices[i] = new Vertex3DNoTex2 {
+ X = 0.0f,
+ Y = ((_data.Width + _zHeight) * _zScale) * ScaleInv,
+ Z = -(_desc.c[0].y + yTip) * ScaleInv,
+ Nx = 0.0f,
+ Ny = 0.0f,
+ Nz = 1.0f,
+ Tu = 0.5f,
+ Tv = _desc.c[0].tv
+ };
+
+ return vertices;
+ }
///
/// Build the spring.
@@ -476,12 +510,15 @@ private Mesh BuildSpringMesh()
public Vertex3DNoTex2[] BuildSpringVertices(int frame)
{
- if (_lathePoints == 0) {
- CalculateArraySizes();
- }
- var vertices = new Vertex3DNoTex2[_springVts];
-
- var springGaugeRel = _springGauge / _data.Width;
+ if (_lathePoints == 0) {
+ CalculateArraySizes();
+ }
+ var vertices = new Vertex3DNoTex2[_springVts];
+ if (_springVts == 0) {
+ return vertices;
+ }
+
+ var springGaugeRel = _springGauge / _data.Width;
var yTip = _beginY + _dyPerFrame * frame;
ref var c = ref _desc.c[_lathePoints - 2];
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs
new file mode 100644
index 000000000..0ab088681
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs
@@ -0,0 +1,224 @@
+// 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 FluentAssertions;
+using NUnit.Framework;
+using Unity.Mathematics;
+using UnityEngine;
+using VisualPinball.Engine.VPT;
+using EnginePlunger = VisualPinball.Engine.VPT.Plunger.Plunger;
+using Random = Unity.Mathematics.Random;
+
+namespace VisualPinball.Unity.Test
+{
+ public class PlungerPhysicsTests
+ {
+ [Test]
+ public void ShouldFireProportionallyToPullDistance()
+ {
+ var staticState = CreateStaticState();
+
+ var rest = FireFrom(0.0f, staticState);
+ var half = FireFrom(0.5f, staticState);
+ var full = FireFrom(1.0f, staticState);
+
+ rest.FireSpeed.Should().BeApproximately(0.0f, 0.0001f);
+ half.FireSpeed.Should().BeLessThan(0.0f);
+ full.FireSpeed.Should().BeLessThan(half.FireSpeed);
+
+ var halfDistance = 0.5f - staticState.RestPosition;
+ var fullDistance = 1.0f - staticState.RestPosition;
+ half.FireSpeed.Should().BeApproximately(full.FireSpeed * halfDistance / fullDistance, 0.0001f);
+ }
+
+ [Test]
+ public void ShouldNotApplyMechStrengthToNonMechanicalPlunger()
+ {
+ var staticState = CreateStaticState();
+ staticState.IsMechPlunger = false;
+ var movementWithNoStrength = CreateMovement(staticState);
+ var movementWithStrength = CreateMovement(staticState);
+ movementWithNoStrength.AnalogPosition = 1.0f;
+ movementWithStrength.AnalogPosition = 1.0f;
+
+ var velocityWithNoStrength = new PlungerVelocityState { MechStrength = 0.0f };
+ var velocityWithStrength = new PlungerVelocityState { MechStrength = 100.0f };
+
+ PlungerVelocityPhysics.UpdateVelocities(ref movementWithNoStrength, ref velocityWithNoStrength, in staticState);
+ PlungerVelocityPhysics.UpdateVelocities(ref movementWithStrength, ref velocityWithStrength, in staticState);
+
+ movementWithStrength.Speed.Should().BeApproximately(movementWithNoStrength.Speed, 0.0001f);
+ movementWithStrength.Position.Should().BeApproximately(movementWithNoStrength.Position, 0.0001f);
+ }
+
+ [Test]
+ public void ShouldEnableRetractMotionForNormalPlungerPullBackAndRetract()
+ {
+ var movement = new PlungerMovementState { RetractMotion = true };
+ var velocity = new PlungerVelocityState();
+
+ PlungerCommands.PullBackAndRetract(3.0f, false, ref velocity, ref movement);
+
+ velocity.AddRetractMotion.Should().BeTrue();
+ velocity.InitialSpeed.Should().Be(3.0f);
+ movement.RetractMotion.Should().BeFalse();
+ }
+
+ [Test]
+ public void ShouldDisableRetractMotionForAutoPlungerPullBackAndRetract()
+ {
+ var movement = new PlungerMovementState { RetractMotion = true };
+ var velocity = new PlungerVelocityState();
+
+ PlungerCommands.PullBackAndRetract(3.0f, true, ref velocity, ref movement);
+
+ velocity.AddRetractMotion.Should().BeFalse();
+ velocity.InitialSpeed.Should().Be(3.0f);
+ movement.RetractMotion.Should().BeFalse();
+ }
+
+ [Test]
+ public void ShouldGateAnalogPositionToMechanicalPlungers()
+ {
+ var staticState = CreateStaticState();
+ staticState.IsMechPlunger = true;
+ var mechanicalMovement = CreateMovement(staticState);
+ mechanicalMovement.AnalogPosition = 0.75f;
+ var mechanicalVelocity = new PlungerVelocityState { MechStrength = 100.0f };
+
+ var nonMechanicalState = staticState;
+ nonMechanicalState.IsMechPlunger = false;
+ var nonMechanicalMovement = CreateMovement(nonMechanicalState);
+ nonMechanicalMovement.AnalogPosition = 0.75f;
+ var nonMechanicalVelocity = new PlungerVelocityState { MechStrength = 100.0f };
+
+ PlungerVelocityPhysics.UpdateVelocities(ref mechanicalMovement, ref mechanicalVelocity, in staticState);
+ PlungerVelocityPhysics.UpdateVelocities(ref nonMechanicalMovement, ref nonMechanicalVelocity, in nonMechanicalState);
+
+ mechanicalMovement.Speed.Should().NotBeApproximately(nonMechanicalMovement.Speed, 0.0001f);
+ nonMechanicalMovement.Speed.Should().BeApproximately(0.0f, 0.0001f);
+ }
+
+ [Test]
+ public void ShouldBuildColliderInPlungerLocalZ()
+ {
+ var go = new GameObject("plunger");
+ try {
+ var comp = go.AddComponent();
+ comp.Position = new Vector3(100.0f, 200.0f, 17.0f);
+ var collComp = go.AddComponent();
+
+ var collider = new PlungerCollider(comp, collComp, new ColliderInfo {
+ ItemId = 1,
+ ItemType = ItemType.Plunger
+ });
+
+ collider.LineSegBase.ZLow.Should().Be(0.0f);
+ collider.LineSegBase.ZHigh.Should().Be(EnginePlunger.PlungerHeight);
+ collider.Bounds.Aabb.ZLow.Should().Be(0.0f);
+ collider.Bounds.Aabb.ZHigh.Should().Be(EnginePlunger.PlungerHeight);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldTreatRotatedAndScaledPlungersAsNonTransformable()
+ {
+ PlungerCollider.IsTransformable(float4x4.Translate(new float3(10.0f, 20.0f, 30.0f))).Should().BeTrue();
+ PlungerCollider.IsTransformable(float4x4.RotateZ(math.radians(15.0f))).Should().BeFalse();
+ PlungerCollider.IsTransformable(float4x4.RotateX(math.radians(15.0f))).Should().BeFalse();
+ PlungerCollider.IsTransformable(float4x4.Scale(2.0f)).Should().BeFalse();
+ }
+
+ [Test]
+ public void ShouldRoundTripPlungerTipVelocityThroughRotatedCollisionSpace()
+ {
+ var ball = new BallState {
+ Position = new float3(0.0f, 0.0f, 1.0f),
+ Velocity = new float3(0.0f, -1.0f, 0.0f),
+ Radius = 1.0f,
+ Mass = 1.0f,
+ CollisionEvent = new CollisionEventData {
+ HitNormal = new float3(0.0f, 1.0f, 0.0f),
+ HitVelocity = new float2(0.0f, -12.0f)
+ }
+ };
+ var rotation = float4x4.RotateX(math.radians(45.0f));
+
+ ball.Transform(rotation);
+ ball.Transform(math.inverse(rotation));
+
+ ball.CollisionEvent.HitNormal.x.Should().BeApproximately(0.0f, 0.0001f);
+ ball.CollisionEvent.HitNormal.y.Should().BeApproximately(1.0f, 0.0001f);
+ ball.CollisionEvent.HitNormal.z.Should().BeApproximately(0.0f, 0.0001f);
+ ball.CollisionEvent.HitVelocity.x.Should().BeApproximately(0.0f, 0.0001f);
+ ball.CollisionEvent.HitVelocity.y.Should().BeApproximately(-12.0f, 0.0001f);
+ }
+
+ [Test]
+ public void ShouldNotImpactWhenKinematicSurfaceRecedesFasterThanBall()
+ {
+ var staticState = CreateStaticState();
+ var movement = CreateMovement(staticState);
+ var ball = new BallState {
+ Position = new float3(0.0f, 0.0f, 0.0f),
+ Velocity = new float3(0.0f, -1.0f, 0.0f),
+ Radius = 1.0f,
+ Mass = 1.0f
+ };
+ var collision = new CollisionEventData {
+ HitNormal = new float3(0.0f, 1.0f, 0.0f),
+ HitDistance = 0.0f
+ };
+ var random = new Random(1);
+
+ PlungerCollider.Collide(ref ball, ref collision, ref movement, in staticState,
+ new float3(0.0f, -10.0f, 0.0f), ref random);
+
+ ball.Velocity.y.Should().BeApproximately(-1.0f, 0.0001f);
+ }
+
+ private static PlungerMovementState FireFrom(float startPos, PlungerStaticState staticState)
+ {
+ var movement = CreateMovement(staticState);
+ var velocity = new PlungerVelocityState();
+
+ PlungerCommands.Fire(startPos, ref velocity, ref movement, in staticState);
+ return movement;
+ }
+
+ private static PlungerMovementState CreateMovement(PlungerStaticState staticState)
+ {
+ return new PlungerMovementState {
+ Position = staticState.FrameEnd + staticState.RestPosition * staticState.FrameLen,
+ TravelLimit = staticState.FrameEnd
+ };
+ }
+
+ private static PlungerStaticState CreateStaticState()
+ {
+ return new PlungerStaticState {
+ FrameStart = 0.0f,
+ FrameEnd = -80.0f,
+ FrameLen = 80.0f,
+ RestPosition = 1.0f / 6.0f,
+ SpeedFire = 80.0f,
+ IsMechPlunger = false
+ };
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta
new file mode 100644
index 000000000..ba9345662
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f2a3c3906c5a4d9e9dff5b6fc4693d84
+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..d5af3697d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
@@ -115,9 +115,15 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
using (var enumerator = state.PlungerStates.GetEnumerator()) {
while (enumerator.MoveNext()) {
ref var plungerState = ref enumerator.Current.Value;
- ref var plungerCollider = ref state.Colliders.Plunger(plungerState.Static.ColliderId);
- PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider,
- in plungerState.Static, hitTime, ref state.EventQueue);
+ if (plungerState.Static.IsKinematic) {
+ ref var plungerCollider = ref state.KinematicColliders.Plunger(plungerState.Static.ColliderId);
+ PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider,
+ in plungerState.Static, hitTime, ref state.EventQueue);
+ } else {
+ ref var plungerCollider = ref state.Colliders.Plunger(plungerState.Static.ColliderId);
+ PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider,
+ in plungerState.Static, hitTime, ref state.EventQueue);
+ }
}
}
// spinners
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
index 953bef7d4..ae3efb4b6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
@@ -133,10 +133,15 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r
in ball.CollisionEvent, ref state);
break;
- case ColliderType.Plunger:
- ref var plungerState = ref state.GetPlungerState(colliderId, ref colliders);
- PlungerCollider.Collide(ref ball, ref ball.CollisionEvent, ref plungerState.Movement, in plungerState.Static, ref state.Env.Random);
- break;
+ case ColliderType.Plunger:
+ ref var plungerState = ref state.GetPlungerState(colliderId, ref colliders);
+ var plungerSurfaceVelocity = state.GetKinematicSurfaceVelocity(
+ in ball.CollisionEvent,
+ ball.Position - ball.Radius * ball.CollisionEvent.HitNormal
+ );
+ PlungerCollider.Collide(ref ball, ref ball.CollisionEvent, ref plungerState.Movement,
+ in plungerState.Static, in plungerSurfaceVelocity, ref state.Env.Random);
+ break;
case ColliderType.Spinner:
ref var spinnerState = ref state.GetSpinnerState(colliderId, ref colliders);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
index 5f9446464..efabb3783 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs
@@ -289,9 +289,10 @@ private void OnDestroy()
_wirePlayer.OnDestroy();
_displayPlayer.OnDestroy();
- foreach (var (action, callback) in _actions) {
- action.performed -= callback;
- }
+ foreach (var (action, callback) in _actions) {
+ action.performed -= callback;
+ action.canceled -= callback;
+ }
}
#endregion
@@ -300,12 +301,13 @@ private void OnDestroy()
public void Register(PlungerApi plungerApi, PlungerComponent component, InputActionReference actionRef)
{
- Register(plungerApi, component);
- if (actionRef != null) {
- actionRef.action.performed += plungerApi.OnAnalogPlunge;
- _actions.Add((actionRef.action, plungerApi.OnAnalogPlunge));
- }
- }
+ Register(plungerApi, component);
+ if (actionRef != null) {
+ actionRef.action.performed += plungerApi.OnAnalogPlunge;
+ actionRef.action.canceled += plungerApi.OnAnalogPlunge;
+ _actions.Add((actionRef.action, plungerApi.OnAnalogPlunge));
+ }
+ }
public void Register(PlayfieldApi playfieldApi)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs
index 26c3c6f76..1e1f4b83d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs
@@ -172,7 +172,7 @@ public static InputActionAsset GetDefaultInputActionAsset()
map.AddAction(InputConstants.ActionFrontBuyIn, InputActionType.Button, "/2");
map.AddAction(InputConstants.ActionStartGame, InputActionType.Button, "/1");
map.AddAction(InputConstants.ActionPlunger, InputActionType.Button, "/enter");
- map.AddAction(InputConstants.ActionPlungerAnalog, InputActionType.Button, "/rightStick/down");
+ map.AddAction(InputConstants.ActionPlungerAnalog, InputActionType.Value, "/rightStick/y", processors: "invert,axisDeadzone");
map.AddAction(InputConstants.ActionInsertCoin1, InputActionType.Button, "/5");
map.AddAction(InputConstants.ActionInsertCoin2, InputActionType.Button, "/4");
map.AddAction(InputConstants.ActionInsertCoin3, InputActionType.Button, "/3");
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs
index 99192ba92..bfa86357b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs
@@ -64,7 +64,7 @@ public class PlungerApi : CollidableApi(); // 0 = resting pos, 1 = pulled back
+ var pos = math.clamp(ctx.ReadValue(), 0.0f, 1.0f); // 0 = resting pos, 1 = pulled back
PhysicsEngine.MutateState((ref PhysicsState state) => {
ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId);
plungerState.Movement.AnalogPosition = pos;
@@ -101,10 +101,11 @@ public void PullBack()
var doRetract = DoRetract;
var speedPull = collComponent.SpeedPull;
+ var isAutoPlunger = collComponent.IsAutoPlunger;
PhysicsEngine.MutateState((ref PhysicsState state) => {
ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId);
if (doRetract) {
- PlungerCommands.PullBackAndRetract(speedPull, ref plungerState.Velocity, ref plungerState.Movement);
+ PlungerCommands.PullBackAndRetract(speedPull, isAutoPlunger, ref plungerState.Velocity, ref plungerState.Movement);
} else {
PlungerCommands.PullBack(speedPull, ref plungerState.Velocity, ref plungerState.Movement);
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
index a25138fb3..7aa0ad0cd 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
@@ -63,7 +63,7 @@ public PlungerCollider(PlungerComponent comp, PlungerColliderComponent collComp,
{
Header.Init(info, ColliderType.Plunger);
- var zHeight = comp.Position.z;
+ const float zHeight = 0.0f;
var x = -comp.Width;
var x2 = comp.Width;
var y = comp.Height;
@@ -90,12 +90,12 @@ public PlungerCollider(PlungerComponent comp, PlungerColliderComponent collComp,
Bounds = new ColliderBounds(Header.ItemId, Header.Id, new Aabb(
x - 0.1f,
x2 + 0.1f,
- frameTop - 0.1f,
- y + 0.1f,
- 0,
- 50
- ));
- }
+ frameTop - 0.1f,
+ y + 0.1f,
+ zHeight,
+ zHeight + Plunger.PlungerHeight
+ ));
+ }
#region Transformation
@@ -310,11 +310,11 @@ private static void UpdateCollision(ref CollisionEventData collEvent,
#region Collision
- public static void Collide(ref BallState ball, ref CollisionEventData collEvent,
- ref PlungerMovementState movement, in PlungerStaticState staticState, ref Random random)
- {
- var dot = (ball.Velocity.x - collEvent.HitVelocity.x) * collEvent.HitNormal.x
- + (ball.Velocity.y - collEvent.HitVelocity.y) * collEvent.HitNormal.y;
+ public static void Collide(ref BallState ball, ref CollisionEventData collEvent,
+ ref PlungerMovementState movement, in PlungerStaticState staticState, in float3 surfaceVelocity, ref Random random)
+ {
+ var hitVelocity = new float3(collEvent.HitVelocity, 0.0f) + surfaceVelocity;
+ var dot = math.dot(ball.Velocity - hitVelocity, collEvent.HitNormal);
// HACK to stop the ball from spinning.
ball.AngularMomentum.z *= 0.6f;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs
index 8cf8a1b17..c50a865b5 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs
@@ -19,25 +19,28 @@ namespace VisualPinball.Unity
internal static class PlungerCommands
{
- public static void PullBack(float speed, ref PlungerVelocityState velocity, ref PlungerMovementState movement)
- {
- movement.Speed = 0.0f;
- velocity.PullForce = speed;
-
- // deactivate the retract code
- velocity.AddRetractMotion = false;
- }
-
- public static void PullBackAndRetract(float speedPull, ref PlungerVelocityState velocity, ref PlungerMovementState movement)
- {
- movement.Speed = 0.0f;
- velocity.PullForce = speedPull;
-
- // deactivate the retract code
- velocity.AddRetractMotion = false;
- movement.RetractMotion = false;
- velocity.InitialSpeed = speedPull;
- }
+ public static void PullBack(float speed, ref PlungerVelocityState velocity, ref PlungerMovementState movement)
+ {
+ movement.Speed = 0.0f;
+ velocity.PullForce = speed;
+
+ // deactivate the retract code
+ velocity.AddRetractMotion = false;
+ movement.RetractMotion = false;
+ velocity.InitialSpeed = 0.0f;
+ velocity.RetractWaitLoop = 0;
+ }
+
+ public static void PullBackAndRetract(float speedPull, bool isAutoPlunger, ref PlungerVelocityState velocity, ref PlungerMovementState movement)
+ {
+ movement.Speed = 0.0f;
+ velocity.PullForce = speedPull;
+
+ velocity.AddRetractMotion = !isAutoPlunger;
+ movement.RetractMotion = false;
+ velocity.InitialSpeed = speedPull;
+ velocity.RetractWaitLoop = 0;
+ }
public static void Fire(float startPos, ref PlungerVelocityState velocity, ref PlungerMovementState movement, in PlungerStaticState staticState)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs
index 1ac64443c..f6f40e552 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs
@@ -280,10 +280,11 @@ internal PlungerState CreateState()
var position = frameTop + restPos * frameLen;
return new PlungerState(
- new PlungerStaticState {
- MomentumXfer = collComponent.MomentumXfer,
- ScatterVelocity = collComponent.ScatterVelocity,
- FrameStart = frameBottom,
+ new PlungerStaticState {
+ IsKinematic = collComponent.IsKinematic,
+ MomentumXfer = collComponent.MomentumXfer,
+ ScatterVelocity = collComponent.ScatterVelocity,
+ FrameStart = frameBottom,
FrameEnd = frameTop,
FrameLen = frameLen,
RestPosition = restPos,
@@ -303,9 +304,9 @@ internal PlungerState CreateState()
FireTimer = 0
},
new PlungerVelocityState {
- Mech0 = 0f,
- Mech1 = 0f,
- Mech2 = 0f,
+ Mech0 = restPos,
+ Mech1 = restPos,
+ Mech2 = restPos,
PullForce = 0f,
InitialSpeed = 0f,
AutoFireTimer = 0,
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs
index 1d0e51982..7c3c03a0c 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs
@@ -27,10 +27,10 @@ namespace VisualPinball.Unity
[AddComponentMenu("Pinball/Mesh/Plunger Flat Mesh")]
public class PlungerFlatMeshComponent : PlungerMeshComponent
{
- protected override Mesh GetMesh(PlungerData data)
- => new PlungerMeshGenerator(data)
- .GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Flat)
- .TransformToWorld();
+ protected override Mesh GetMesh(PlungerData data)
+ => new PlungerMeshGenerator(data)
+ .GetLocalMesh(PlungerMeshGenerator.Flat)
+ .TransformToWorld();
protected override PbrMaterial GetMaterial(PlungerData data, Table table)
=> new PlungerMeshGenerator(data).GetMaterial(table);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs
index 02e8439f5..68488b3ac 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs
@@ -56,9 +56,9 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac
#endregion
- protected override Mesh GetMesh(PlungerData data)
- => new PlungerMeshGenerator(data)
- .GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Rod);
+ protected override Mesh GetMesh(PlungerData data)
+ => new PlungerMeshGenerator(data)
+ .GetLocalMesh(PlungerMeshGenerator.Rod);
protected override PbrMaterial GetMaterial(PlungerData data, Table table)
=> new PlungerMeshGenerator(data).GetMaterial(table);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs
index 5bbbc0566..de9056a85 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs
@@ -53,8 +53,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac
#endregion
- protected override Mesh GetMesh(PlungerData data)
- => new PlungerMeshGenerator(data).GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Spring);
+ protected override Mesh GetMesh(PlungerData data)
+ => new PlungerMeshGenerator(data).GetLocalMesh(PlungerMeshGenerator.Spring);
protected override PbrMaterial GetMaterial(PlungerData data, Table table)
=> new PlungerMeshGenerator(data).GetMaterial(table);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs
index 195bf81f9..885f11319 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs
@@ -16,12 +16,13 @@
namespace VisualPinball.Unity
{
- internal struct PlungerStaticState
- {
- public int ColliderId;
-
- // collision
- public float MomentumXfer;
+ internal struct PlungerStaticState
+ {
+ public int ColliderId;
+ public bool IsKinematic;
+
+ // collision
+ public float MomentumXfer;
public float ScatterVelocity;
// displacement
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs
index daf53c805..6bcb8edf6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs
@@ -14,7 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-using VisualPinball.Engine.VPT.Plunger;
+using Unity.Mathematics;
+using VisualPinball.Engine.VPT.Plunger;
namespace VisualPinball.Unity
{
@@ -28,7 +29,9 @@ internal static void UpdateVelocities(ref PlungerMovementState movement, ref Plu
// maximum retracted position)
var pos = (movement.Position - staticState.FrameEnd) / staticState.FrameLen;
- var mech = staticState.IsMechPlunger ? movement.AnalogPosition : 0.0f;
+ var mech = staticState.IsMechPlunger
+ ? math.clamp(math.lerp(staticState.RestPosition, 1.0f, movement.AnalogPosition), 0.0f, 1.0f)
+ : staticState.RestPosition;
// calculate the delta from the last reading
var dMech = velocity.Mech0 - mech;