From 446e9f709ff470356e45073bbbd8cf4bac43603e Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 18:27:23 +0200 Subject: [PATCH 01/50] physics(magnets): add radial magnet device Introduce the magnet component, API, native state map, VPX-compatible radial force update, editor inspector, toolbox prefab, and focused force-scaling coverage for the first magnet milestone. --- VisualPinball.Engine/Game/EventId.cs | 18 +- .../Assets/Resources/Prefabs/Magnet.prefab | 54 +++++ .../Resources/Prefabs/Magnet.prefab.meta | 7 + .../Toolbox/ToolboxEditor.cs | 21 +- .../VPT/Magnet.meta | 8 + .../VPT/Magnet/MagnetInspector.cs | 74 +++++++ .../VPT/Magnet/MagnetInspector.cs.meta | 11 ++ .../Physics/MagnetPhysicsTests.cs | 75 +++++++ .../Physics/MagnetPhysicsTests.cs.meta | 11 ++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 6 + .../Game/PhysicsEngineContext.cs | 4 +- .../VisualPinball.Unity/Game/PhysicsState.cs | 31 +-- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 23 ++- .../VisualPinball.Unity/Game/Player.cs | 48 +++-- .../VisualPinball.Unity/VPT/EventArgs.cs | 31 +-- .../VisualPinball.Unity/VPT/Magnet.meta | 8 + .../VPT/Magnet/MagnetApi.cs | 146 ++++++++++++++ .../VPT/Magnet/MagnetApi.cs.meta | 11 ++ .../VPT/Magnet/MagnetComponent.cs | 187 ++++++++++++++++++ .../VPT/Magnet/MagnetComponent.cs.meta | 11 ++ .../VPT/Magnet/MagnetForceProfile.cs | 24 +++ .../VPT/Magnet/MagnetForceProfile.cs.meta | 11 ++ .../VPT/Magnet/MagnetPackable.cs | 57 ++++++ .../VPT/Magnet/MagnetPackable.cs.meta | 11 ++ .../VPT/Magnet/MagnetPhysics.cs | 115 +++++++++++ .../VPT/Magnet/MagnetPhysics.cs.meta | 11 ++ .../VPT/Magnet/MagnetState.cs | 35 ++++ .../VPT/Magnet/MagnetState.cs.meta | 11 ++ .../VisualPinball.Unity/VPT/Table/TableApi.cs | 60 +++--- 29 files changed, 1035 insertions(+), 85 deletions(-) create mode 100644 VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab create mode 100644 VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta diff --git a/VisualPinball.Engine/Game/EventId.cs b/VisualPinball.Engine/Game/EventId.cs index ed8e52253..8fb9089c3 100644 --- a/VisualPinball.Engine/Game/EventId.cs +++ b/VisualPinball.Engine/Game/EventId.cs @@ -32,9 +32,15 @@ public enum EventId TargetEventsRaised = 1303, // DISPID_TargetEvents_Raised // Generic - HitEventsHit = 1400, // DISPID_HitEvents_Hit - HitEventsUnhit = 1401, // DISPID_HitEvents_Unhit - LimitEventsEos = 1402, // DISPID_LimitEvents_EOS - LimitEventsBos = 1403, // DISPID_LimitEvents_BOS - } -} + HitEventsHit = 1400, // DISPID_HitEvents_Hit + HitEventsUnhit = 1401, // DISPID_HitEvents_Unhit + LimitEventsEos = 1402, // DISPID_LimitEvents_EOS + LimitEventsBos = 1403, // DISPID_LimitEvents_BOS + + // Magnet + MagnetEventsBallEntered = 1500, + MagnetEventsBallExited = 1501, + MagnetEventsBallGrabbed = 1502, + MagnetEventsBallReleased = 1503, + } +} diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab new file mode 100644 index 000000000..f76d0175c --- /dev/null +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab @@ -0,0 +1,54 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &8456101019218624000 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8456101019218624001} + - component: {fileID: 8456101019218624002} + m_Layer: 0 + m_Name: Magnet + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8456101019218624001 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8456101019218624000} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &8456101019218624002 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8456101019218624000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bf48c83fc8414ae7bb8c31f1b1854b83, type: 3} + m_Name: + m_EditorClassIdentifier: VisualPinball.Unity::VisualPinball.Unity.MagnetComponent + Radius: 50 + Strength: 10 + ForceProfile: 0 + GrabBall: 0 + GrabRadius: 10.8 + HeightRange: 50 + IsEnabledOnStart: 0 + DrawDebugForces: 0 diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta new file mode 100644 index 000000000..5618822c3 --- /dev/null +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c18118d9d47048d5af0a9f1149d32d19 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs index 22efffe72..1027d9735 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs @@ -178,13 +178,20 @@ private void OnGUI() GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); - if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) { - CreatePrefab("Slingshots", "Prefabs/Slingshot"); - } - - if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) { - CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide"); - } + if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) { + CreatePrefab("Slingshots", "Prefabs/Slingshot"); + } + + if (CreateButton("Magnet", Icons.Coil(color: iconColor), iconSize, buttonStyle)) { + CreatePrefab("Magnets", "Prefabs/Magnet"); + } + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) { + CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide"); + } GUILayout.EndHorizontal(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta new file mode 100644 index 000000000..efe3a4080 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f7f581e7f1f25a4b8308ac0540b71ce +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs new file mode 100644 index 000000000..e369763a7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -0,0 +1,74 @@ +// 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 UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(MagnetComponent))] + public class MagnetInspector : ItemInspector + { + private SerializedProperty _radiusProperty; + private SerializedProperty _strengthProperty; + private SerializedProperty _forceProfileProperty; + private SerializedProperty _grabBallProperty; + private SerializedProperty _grabRadiusProperty; + private SerializedProperty _heightRangeProperty; + private SerializedProperty _isEnabledOnStartProperty; + private SerializedProperty _drawDebugForcesProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _radiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.Radius)); + _strengthProperty = serializedObject.FindProperty(nameof(MagnetComponent.Strength)); + _forceProfileProperty = serializedObject.FindProperty(nameof(MagnetComponent.ForceProfile)); + _grabBallProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabBall)); + _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); + _heightRangeProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeightRange)); + _isEnabledOnStartProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsEnabledOnStart)); + _drawDebugForcesProperty = serializedObject.FindProperty(nameof(MagnetComponent.DrawDebugForces)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + OnPreInspectorGUI(); + + PropertyField(_radiusProperty); + PropertyField(_heightRangeProperty); + PropertyField(_strengthProperty); + PropertyField(_forceProfileProperty); + + EditorGUILayout.Space(8f); + PropertyField(_grabBallProperty); + if (_grabBallProperty.boolValue) { + PropertyField(_grabRadiusProperty); + } + + EditorGUILayout.Space(8f); + PropertyField(_isEnabledOnStartProperty); + PropertyField(_drawDebugForcesProperty); + + base.OnInspectorGUI(); + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta new file mode 100644 index 000000000..ba93e9a1b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b1e218c7a248554596d2a1cc5396ee7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs new file mode 100644 index 000000000..71244d137 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -0,0 +1,75 @@ +// 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.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class MagnetPhysicsTests + { + [Test] + public void VpxCompatibleForceScalesToOneMillisecondTicks() + { + var oneTickBall = CreateBall(); + var tenTickBall = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 10f, + PlanarDamping = 1f + }; + + MagnetPhysics.ApplyVpxCompatibleForce(ref oneTickBall, in magnet, 1f); + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref tenTickBall, in magnet, 0.1f); + } + + Assert.That(tenTickBall.Velocity.x, Is.EqualTo(oneTickBall.Velocity.x).Within(1e-5f)); + Assert.That(tenTickBall.Velocity.y, Is.EqualTo(oneTickBall.Velocity.y).Within(1e-5f)); + } + + [Test] + public void PlanarDampingUsesFrameFractionExponent() + { + var ball = CreateBall(); + ball.Velocity = new float3(3f, -4f, 5f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 0f, + PlanarDamping = 0.985f + }; + + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 0.1f); + } + + Assert.That(ball.Velocity.x, Is.EqualTo(3f * 0.985f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-4f * 0.985f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + private static BallState CreateBall() + { + return new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(0f, 0f, 0f) + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta new file mode 100644 index 000000000..668f598e1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91ca942c5b554c97bb532ad05c62493e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index a078384bd..6df126fbe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -703,6 +703,11 @@ internal ref KickerState KickerState(int itemId) GuardLiveStateAccess(nameof(KickerState)); return ref _ctx.KickerStates.Ref.GetValueByRef(itemId); } + internal ref MagnetState MagnetState(int itemId) + { + GuardLiveStateAccess(nameof(MagnetState)); + return ref _ctx.MagnetStates.Ref.GetValueByRef(itemId); + } internal ref PlungerState PlungerState(int itemId) { GuardLiveStateAccess(nameof(PlungerState)); @@ -762,6 +767,7 @@ internal void Register(T item) where T : MonoBehaviour case DropTargetComponent c: _ctx.DropTargetStates.Ref[itemId] = c.CreateState(); break; case HitTargetComponent c: _ctx.HitTargetStates.Ref[itemId] = c.CreateState(); break; case KickerComponent c: _ctx.KickerStates.Ref[itemId] = c.CreateState(); break; + case MagnetComponent c: _ctx.MagnetStates.Ref[itemId] = c.CreateState(); break; case PlungerComponent c: _ctx.PlungerStates.Ref[itemId] = c.CreateState(); break; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index b96032d1e..71d2e4ee4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -110,6 +110,7 @@ internal class PhysicsEngineContext : IDisposable public readonly LazyInit> DropTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> HitTargetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> KickerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + public readonly LazyInit> MagnetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> PlungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SpinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SurfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); @@ -297,7 +298,7 @@ internal PhysicsState CreateState() ref KinematicCollidersAtIdentity, ref KinematicTransforms.Ref, ref KinematicTargetTransforms.Ref, ref NonTransformableColliderTransforms.Ref, ref KinematicColliderLookups, ref events, ref InsideOfs, ref BallStates.Ref, ref BumperStates.Ref, ref DropTargetStates.Ref, ref FlipperStates.Ref, ref GateStates.Ref, - ref HitTargetStates.Ref, ref KickerStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, + ref HitTargetStates.Ref, ref KickerStates.Ref, ref MagnetStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, ref SurfaceStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref); } @@ -336,6 +337,7 @@ public void Dispose() } } KickerStates.Ref.Dispose(); + MagnetStates.Ref.Dispose(); PlungerStates.Ref.Dispose(); SpinnerStates.Ref.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 90f7437b3..87dfc4a8c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -135,10 +135,11 @@ internal struct PhysicsState internal NativeParallelHashMap BumperStates; internal NativeParallelHashMap DropTargetStates; internal NativeParallelHashMap FlipperStates; - internal NativeParallelHashMap GateStates; - internal NativeParallelHashMap HitTargetStates; - internal NativeParallelHashMap KickerStates; - internal NativeParallelHashMap PlungerStates; + internal NativeParallelHashMap GateStates; + internal NativeParallelHashMap HitTargetStates; + internal NativeParallelHashMap KickerStates; + internal NativeParallelHashMap MagnetStates; + internal NativeParallelHashMap PlungerStates; internal NativeParallelHashMap SpinnerStates; internal NativeParallelHashMap SurfaceStates; internal NativeParallelHashMap TriggerStates; @@ -154,11 +155,12 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native ref NativeParallelHashMap nonTransformableColliderTransforms, ref NativeParallelHashMap kinematicColliderLookups, ref NativeQueue.ParallelWriter eventQueue, ref InsideOfs insideOfs, ref NativeParallelHashMap balls, - ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, - ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, - ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, - ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, - ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap triggerStates, + ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, + ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, + ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, + ref NativeParallelHashMap magnetStates, + ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, + ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap triggerStates, ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling, ref NativeParallelHashMap> elasticityOverVelocityLUTs, ref NativeParallelHashMap> frictionOverVelocityLUTs, @@ -179,10 +181,11 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native BumperStates = bumperStates; DropTargetStates = dropTargetStates; FlipperStates = flipperStates; - GateStates = gateStates; - HitTargetStates = hitTargetStates; - KickerStates = kickerStates; - PlungerStates = plungerStates; + GateStates = gateStates; + HitTargetStates = hitTargetStates; + KickerStates = kickerStates; + MagnetStates = magnetStates; + PlungerStates = plungerStates; SpinnerStates = spinnerStates; SurfaceStates = surfaceStates; TriggerStates = triggerStates; @@ -441,4 +444,4 @@ private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId) #endregion } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index ad5e5f469..74f79b157 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -141,14 +141,21 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } } // spinners - using (var enumerator = state.SpinnerStates.GetEnumerator()) { - while (enumerator.MoveNext()) { - ref var spinnerState = ref enumerator.Current.Value; - SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); - } - } - - #endregion + using (var enumerator = state.SpinnerStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var spinnerState = ref enumerator.Current.Value; + SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); + } + } + // magnets + using (var enumerator = state.MagnetStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var magnetState = ref enumerator.Current.Value; + MagnetPhysics.Update(enumerator.Current.Key, ref magnetState, ref state, physicsDiffTime); + } + } + + #endregion // primary physics loop cycle.Simulate(ref state, ref overlappingColliders, ref kinematicOctree, ref ballOctree, physicsDiffTime); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index b408eef89..d38613c2f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -89,9 +89,10 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private readonly Dictionary _hittables = new(); private readonly Dictionary _rotatables = new(); private readonly Dictionary _collidables = new(); - private readonly Dictionary _spinnables = new(); - private readonly Dictionary _slingshots = new(); - private readonly Dictionary _droppables = new(); + private readonly Dictionary _spinnables = new(); + private readonly Dictionary _slingshots = new(); + private readonly Dictionary _droppables = new(); + private readonly Dictionary _magnets = new(); internal IEnumerable ColliderGenerators => _colliderGenerators; @@ -324,11 +325,14 @@ public void Register(TApi api, MonoBehaviour component) where TApi : IApi if (api is IApiSlingshot slingshot) { _slingshots[itemId] = slingshot; } - if (api is IApiDroppable droppable) { - _droppables[itemId] = droppable; - } - if (api is IApiSpinnable spinnable) { - _spinnables[itemId] = spinnable; + if (api is IApiDroppable droppable) { + _droppables[itemId] = droppable; + } + if (api is IApiMagnetEvents magnetEvents) { + _magnets[itemId] = magnetEvents; + } + if (api is IApiSpinnable spinnable) { + _spinnables[itemId] = spinnable; } if (api is IApiSwitchDevice switchDevice) { if (component is ISwitchDeviceComponent switchDeviceComponent) { @@ -434,12 +438,28 @@ public void OnEvent(in EventData eventData) _droppables[eventData.ItemId].OnDropStatusChanged(true, eventData.BallId); break; - case EventId.TargetEventsRaised: - _droppables[eventData.ItemId].OnDropStatusChanged(false, eventData.BallId); - break; - - default: - throw new InvalidOperationException($"Unknown event {eventData.EventId} for entity {eventData.ItemId}"); + case EventId.TargetEventsRaised: + _droppables[eventData.ItemId].OnDropStatusChanged(false, eventData.BallId); + break; + + case EventId.MagnetEventsBallEntered: + _magnets[eventData.ItemId].OnMagnetBallEntered(eventData.BallId); + break; + + case EventId.MagnetEventsBallExited: + _magnets[eventData.ItemId].OnMagnetBallExited(eventData.BallId); + break; + + case EventId.MagnetEventsBallGrabbed: + _magnets[eventData.ItemId].OnMagnetBallGrabbed(eventData.BallId); + break; + + case EventId.MagnetEventsBallReleased: + _magnets[eventData.ItemId].OnMagnetBallReleased(eventData.BallId); + break; + + default: + throw new InvalidOperationException($"Unknown event {eventData.EventId} for entity {eventData.ItemId}"); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs index db5f9c759..258430db7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs @@ -30,19 +30,28 @@ public struct RotationEventArgs public float AngleSpeed; } - public struct HitEventArgs - { - public int BallId; - - public HitEventArgs(int ballId) + public struct HitEventArgs + { + public int BallId; + + public HitEventArgs(int ballId) { BallId = ballId; - } - } - - - public readonly struct SwitchEventArgs - { + } + } + + public readonly struct BallEventArgs + { + public readonly int BallId; + + public BallEventArgs(int ballId) + { + BallId = ballId; + } + } + + public readonly struct SwitchEventArgs + { public readonly bool IsEnabled; public readonly int BallId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta new file mode 100644 index 000000000..97e504578 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 89b9f66ef6f0474887d01d1f8ef5f814 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs new file mode 100644 index 000000000..668ae331c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -0,0 +1,146 @@ +// 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 NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiMagnetEvents + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private readonly MagnetComponent _component; + private readonly Player _player; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + + private DeviceCoil _magnetCoil; + private DeviceSwitch _ballHeldSwitch; + private bool _isEnabled; + + public event EventHandler Init; + public event EventHandler BallEntered; + public event EventHandler BallExited; + public event EventHandler BallGrabbed; + public event EventHandler BallReleased; + + internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) + { + _component = go.GetComponentInChildren(); + _player = player; + _physicsEngine = physicsEngine; + _itemId = UnityObjectId.Get(_component.gameObject); + _isEnabled = _component.IsEnabledOnStart; + + _magnetCoil = new DeviceCoil(_player, OnCoilEnabled, OnCoilDisabled, OnCoilEnabledSimThread, OnCoilDisabledSimThread); + _ballHeldSwitch = new DeviceSwitch(MagnetComponent.BallHeldSwitchItem, false, SwitchDefault.NormallyOpen, _player, _physicsEngine); + } + + public bool IsEnabled { + get => _isEnabled; + set => SetEnabled(value); + } + + public float Strength { + get => _component.Strength; + set => _component.Strength = value; + } + + public float Radius { + get => _component.Radius; + set => _component.Radius = value; + } + + public void ReleaseBall() + { + Logger.Debug($"ReleaseBall called on magnet {_component.name}; grab support is enabled in the grab/hold milestone."); + } + + void IApi.OnInit(BallManager ballManager) + { + SetEnabled(_component.IsEnabledOnStart); + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + + IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + IApiSwitch IApiSwitchDevice.Switch(string deviceItem) => Switch(deviceItem); + + private IApiCoil Coil(string deviceItem) + { + return deviceItem switch { + MagnetComponent.MagnetCoilItem => _magnetCoil, + _ => throw new ArgumentException($"Unknown magnet coil \"{deviceItem}\". Valid name is \"{MagnetComponent.MagnetCoilItem}\".") + }; + } + + private IApiSwitch Switch(string deviceItem) + { + return deviceItem switch { + MagnetComponent.BallHeldSwitchItem => _ballHeldSwitch, + _ => throw new ArgumentException($"Unknown magnet switch \"{deviceItem}\". Valid name is \"{MagnetComponent.BallHeldSwitchItem}\".") + }; + } + + private void OnCoilEnabled() => SetEnabled(true); + private void OnCoilDisabled() => SetEnabled(false); + private void OnCoilEnabledSimThread() => SetEnabledSimThread(true); + private void OnCoilDisabledSimThread() => SetEnabledSimThread(false); + + private void SetEnabled(bool enabled) + { + _isEnabled = enabled; + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.IsEnabled = enabled; + } + }); + } + + private void SetEnabledSimThread(bool enabled) + { + _isEnabled = enabled; + if (!_physicsEngine) { + return; + } + ref var magnet = ref _physicsEngine.MagnetState(_itemId); + magnet.IsEnabled = enabled; + } + + void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new BallEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallExited(int ballId) => BallExited?.Invoke(this, new BallEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallGrabbed(int ballId) => BallGrabbed?.Invoke(this, new BallEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallReleased(int ballId) => BallReleased?.Invoke(this, new BallEventArgs(ballId)); + } + + internal interface IApiMagnetEvents + { + void OnMagnetBallEntered(int ballId); + void OnMagnetBallExited(int ballId); + void OnMagnetBallGrabbed(int ballId); + void OnMagnetBallReleased(int ballId); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta new file mode 100644 index 000000000..95e784e14 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04a01977273d4b5ba036ca6c549bbf2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs new file mode 100644 index 000000000..d934ef586 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -0,0 +1,187 @@ +// 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 NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [PackAs("Magnet")] + [AddComponentMenu("Pinball/Mechs/Magnet")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] + public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDeviceComponent, IPackable + { + public const string MagnetCoilItem = "magnet_coil"; + public const string BallHeldSwitchItem = "ball_held"; + public const float MillimetersToWorld = 0.001f; + public const float DefaultPlanarDamping = 0.985f; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Min(0f)] + [Unit("mm")] + [Tooltip("Planar radius in which the magnet influences balls.")] + public float Radius = 50f; + + [Tooltip("Magnet strength. In VPX-compatible mode this uses cvpmMagnet strength values.")] + public float Strength = 10f; + + [Tooltip("How the authored strength value is interpreted.")] + public MagnetForceProfile ForceProfile = MagnetForceProfile.VpxCompatible; + + [Tooltip("Whether the magnet can hold a ball at its center.")] + public bool GrabBall; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Radius around the center where grab mode captures the ball.")] + public float GrabRadius = 10.8f; + + [Min(0f)] + [Unit("mm")] + [Tooltip("Vertical range above the magnet surface where balls are affected.")] + public float HeightRange = 50f; + + [Tooltip("Whether the magnet starts enabled before coil or script control changes it.")] + public bool IsEnabledOnStart; + + [Tooltip("Draw play-mode force vectors for balls inside the radius.")] + public bool DrawDebugForces; + + public byte[] Pack() => MagnetPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => System.Array.Empty(); + + public void Unpack(byte[] bytes) => MagnetPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(MagnetCoilItem) { + Description = "Magnet" + } + }; + + public IEnumerable AvailableSwitches => new[] { + new GamelogicEngineSwitch(BallHeldSwitchItem) { + Description = "Ball Held" + } + }; + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => ((IApiCoilDevice)MagnetApi).Coil(deviceId); + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableSwitches; + IEnumerable IWireableComponent.AvailableWireDestinations => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + + public MagnetApi MagnetApi { get; private set; } + + private void Awake() + { + var player = GetComponentInParent(); + if (player == null) { + Logger.Error($"Cannot find player for magnet {name}."); + return; + } + + var physicsEngine = GetComponentInParent(); + MagnetApi = new MagnetApi(gameObject, player, physicsEngine); + + player.Register(MagnetApi, this); + if (physicsEngine) { + physicsEngine.Register(this); + } else { + Logger.Error($"Cannot find physics engine for magnet {name}."); + } + } + + internal MagnetState CreateState() + { + var pos = transform.localPosition.TranslateToVpx(); + return new MagnetState { + Position = pos.xy, + Height = pos.z, + Radius = MillimetersToVpx(Radius), + Strength = Strength, + GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, + PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), + IsEnabled = IsEnabledOnStart, + Profile = ForceProfile, + HeightRange = MillimetersToVpx(HeightRange), + GrabbedBalls = default + }; + } + + internal static float MillimetersToVpx(float value) => Physics.ScaleToVpx(value * MillimetersToWorld); + + private void OnDrawGizmosSelected() + { + var radiusWorld = Radius * MillimetersToWorld; + var grabRadiusWorld = GrabBall ? GrabRadius * MillimetersToWorld : 0f; + var heightRangeWorld = HeightRange * MillimetersToWorld; + + Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.9f); + DrawLocalDisc(radiusWorld); + + if (GrabBall && grabRadiusWorld > 0f) { + Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.5f); + DrawLocalDisc(grabRadiusWorld); + } + + if (heightRangeWorld > 0f) { + Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.35f); + Gizmos.DrawLine(transform.position, transform.position + transform.up * heightRangeWorld); + } + + if (!Application.isPlaying || !DrawDebugForces) { + return; + } + var player = GetComponentInParent(); + if (!player) { + return; + } + foreach (var ball in player.GetComponentsInChildren()) { + var offset = ball.transform.position - transform.position; + var planarOffset = Vector3.ProjectOnPlane(offset, transform.up); + if (planarOffset.sqrMagnitude <= radiusWorld * radiusWorld) { + Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * Mathf.Min(radiusWorld * 0.25f, planarOffset.magnitude)); + } + } + } + + private void DrawLocalDisc(float radius) + { + if (radius <= 0f) { + return; + } + + const int segments = 64; + var previous = transform.TransformPoint(new Vector3(radius, 0f, 0f)); + for (var i = 1; i <= segments; i++) { + var angle = (math.TAU * i) / segments; + var next = transform.TransformPoint(new Vector3(math.cos(angle) * radius, 0f, math.sin(angle) * radius)); + Gizmos.DrawLine(previous, next); + previous = next; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta new file mode 100644 index 000000000..9beec92fe --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf48c83fc8414ae7bb8c31f1b1854b83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs new file mode 100644 index 000000000..0cd08dfc6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs @@ -0,0 +1,24 @@ +// 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 . + +namespace VisualPinball.Unity +{ + public enum MagnetForceProfile + { + VpxCompatible = 0, + Physical = 1 + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta new file mode 100644 index 000000000..0262e7799 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aaadedeef0cd4bbda654fca5caa60530 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs new file mode 100644 index 000000000..110f10400 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -0,0 +1,57 @@ +// 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 . + +namespace VisualPinball.Unity +{ + public struct MagnetPackable + { + public float Radius; + public float Strength; + public MagnetForceProfile ForceProfile; + public bool GrabBall; + public float GrabRadius; + public float HeightRange; + public bool IsEnabledOnStart; + public bool DrawDebugForces; + + public static byte[] Pack(MagnetComponent comp) + { + return PackageApi.Packer.Pack(new MagnetPackable { + Radius = comp.Radius, + Strength = comp.Strength, + ForceProfile = comp.ForceProfile, + GrabBall = comp.GrabBall, + GrabRadius = comp.GrabRadius, + HeightRange = comp.HeightRange, + IsEnabledOnStart = comp.IsEnabledOnStart, + DrawDebugForces = comp.DrawDebugForces, + }); + } + + public static void Unpack(byte[] bytes, MagnetComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.Radius = data.Radius; + comp.Strength = data.Strength; + comp.ForceProfile = data.ForceProfile; + comp.GrabBall = data.GrabBall; + comp.GrabRadius = data.GrabRadius; + comp.HeightRange = data.HeightRange; + comp.IsEnabledOnStart = data.IsEnabledOnStart; + comp.DrawDebugForces = data.DrawDebugForces; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta new file mode 100644 index 000000000..40003c53a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e1a6d673eb8fb249a7814f7708c4684 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs new file mode 100644 index 000000000..ffbf421f2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -0,0 +1,115 @@ +// 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 Unity.Burst; +using Unity.Mathematics; +using VisualPinball.Engine.Game; + +namespace VisualPinball.Unity +{ + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class MagnetPhysics + { + internal const float VpxMagnetUpdateMs = 10f; + private const float MinDistance = 0.0001f; + + [BurstCompile] + internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) + { + if (!magnet.IsEnabled) { + ReleaseMembership(itemId, ref state); + return; + } + + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + if (ball.IsFrozen) { + UpdateMembership(itemId, ball.Id, false, ref state); + continue; + } + + var affectsBall = IsBallInRange(in ball, in magnet); + UpdateMembership(itemId, ball.Id, affectsBall, ref state); + if (!affectsBall) { + continue; + } + + switch (magnet.Profile) { + case MagnetForceProfile.VpxCompatible: + case MagnetForceProfile.Physical: + ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime); + break; + } + } + } + } + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + { + var delta = ball.Position.xy - magnet.Position; + var distance = math.length(delta); + if (distance <= MinDistance || magnet.Radius <= MinDistance) { + return; + } + + var ratio = distance / magnet.Radius; + var force = magnet.Strength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; + var damping = math.pow(magnet.PlanarDamping, physicsDiffTime); + var direction = delta / distance; + + var velocity = ball.Velocity.xy * damping - direction * force * physicsDiffTime; + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + } + + internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) + { + if (magnet.Radius <= 0f) { + return false; + } + if (magnet.HeightRange > 0f && (ball.Position.z < magnet.Height || ball.Position.z > magnet.Height + magnet.HeightRange)) { + return false; + } + return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; + } + + private static void ReleaseMembership(int itemId, ref PhysicsState state) + { + var ballIds = state.InsideOfs.GetIdsOfBallsInsideItem(itemId); + for (var i = 0; i < ballIds.Length; i++) { + var ballId = ballIds[i]; + state.InsideOfs.SetOutsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallExited, itemId, ballId, true)); + } + } + + private static void UpdateMembership(int itemId, int ballId, bool isInside, ref PhysicsState state) + { + var wasInside = state.InsideOfs.IsInsideOf(itemId, ballId); + if (isInside == wasInside) { + return; + } + + if (isInside) { + state.InsideOfs.SetInsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallEntered, itemId, ballId, true)); + } else { + state.InsideOfs.SetOutsideOf(itemId, ballId); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallExited, itemId, ballId, true)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta new file mode 100644 index 000000000..0d61f3ef8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5044029e35317cd4bb2819aac55908cd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs new file mode 100644 index 000000000..709bc6cfb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -0,0 +1,35 @@ +// 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 Unity.Mathematics; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + internal struct MagnetState + { + internal float2 Position; + internal float Height; + internal float Radius; + internal float Strength; + internal float GrabRadius; + internal float PlanarDamping; + internal bool IsEnabled; + internal MagnetForceProfile Profile; + internal float HeightRange; + internal BitField64 GrabbedBalls; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta new file mode 100644 index 000000000..b75c958e2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fbc1a7f18cfd4cb79f71f0281d0a85be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs index cce1c281d..10e2ea549 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs @@ -31,11 +31,12 @@ public class TableApi : IApi private readonly Dictionary _dropTargetBanksByName = new Dictionary(); private readonly Dictionary _flippersByName = new Dictionary(); private readonly Dictionary _gatesByName = new Dictionary(); - private readonly Dictionary _hitTargetsByName = new Dictionary(); - private readonly Dictionary _kickersByName = new Dictionary(); - private readonly Dictionary _lightsByName = new Dictionary(); - private readonly Dictionary _lightGroupsByName = new Dictionary(); - private readonly Dictionary _plungersByName = new Dictionary(); + private readonly Dictionary _hitTargetsByName = new Dictionary(); + private readonly Dictionary _kickersByName = new Dictionary(); + private readonly Dictionary _lightsByName = new Dictionary(); + private readonly Dictionary _lightGroupsByName = new Dictionary(); + private readonly Dictionary _magnetsByName = new Dictionary(); + private readonly Dictionary _plungersByName = new Dictionary(); private readonly Dictionary _primitivesByName = new Dictionary(); private readonly Dictionary _rampsByName = new Dictionary(); private readonly Dictionary _rubbersByName = new Dictionary(); @@ -54,11 +55,12 @@ public class TableApi : IApi private readonly Dictionary _dropTargetBanksByComponent = new Dictionary(); private readonly Dictionary _flippersByComponent = new Dictionary(); private readonly Dictionary _gatesByComponent = new Dictionary(); - private readonly Dictionary _hitTargetsByComponent = new Dictionary(); - private readonly Dictionary _kickersByComponent = new Dictionary(); - private readonly Dictionary _lightsByComponent = new Dictionary(); - private readonly Dictionary _lightGroupsByComponent = new Dictionary(); - private readonly Dictionary _plungersByComponent = new Dictionary(); + private readonly Dictionary _hitTargetsByComponent = new Dictionary(); + private readonly Dictionary _kickersByComponent = new Dictionary(); + private readonly Dictionary _lightsByComponent = new Dictionary(); + private readonly Dictionary _lightGroupsByComponent = new Dictionary(); + private readonly Dictionary _magnetsByComponent = new Dictionary(); + private readonly Dictionary _plungersByComponent = new Dictionary(); private readonly Dictionary _primitivesByComponent = new Dictionary(); private readonly Dictionary _rampsByComponent = new Dictionary(); private readonly Dictionary _rubbersByComponent = new Dictionary(); @@ -126,10 +128,18 @@ public TableApi(Player player) /// /// Name of the kicker /// Kicker or `null` if no kicker with that name exists. - public KickerApi Kicker(string name) => Get(name); - public KickerApi Kicker(MonoBehaviour component) => Get(component); - - /// + public KickerApi Kicker(string name) => Get(name); + public KickerApi Kicker(MonoBehaviour component) => Get(component); + + /// + /// Returns a magnet by name. + /// + /// Name of the magnet + /// Magnet or `null` if no magnet with that name exists. + public MagnetApi Magnet(string name) => Get(name); + public MagnetApi Magnet(MonoBehaviour component) => Get(component); + + /// /// Returns a light by name. /// /// Name of the light @@ -290,11 +300,12 @@ private Dictionary GetNameDictionary(Type t) where T : IApi if (t == typeof(DropTargetBankApi)) return _dropTargetBanksByName as Dictionary; if (t == typeof(FlipperApi)) return _flippersByName as Dictionary; if (t == typeof(GateApi)) return _gatesByName as Dictionary; - if (t == typeof(HitTargetApi)) return _hitTargetsByName as Dictionary; - if (t == typeof(KickerApi)) return _kickersByName as Dictionary; - if (t == typeof(LightApi)) return _lightsByName as Dictionary; - if (t == typeof(LightGroupApi)) return _lightGroupsByName as Dictionary; - if (t == typeof(PlungerApi)) return _plungersByName as Dictionary; + if (t == typeof(HitTargetApi)) return _hitTargetsByName as Dictionary; + if (t == typeof(KickerApi)) return _kickersByName as Dictionary; + if (t == typeof(LightApi)) return _lightsByName as Dictionary; + if (t == typeof(LightGroupApi)) return _lightGroupsByName as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByName as Dictionary; + if (t == typeof(PlungerApi)) return _plungersByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; if (t == typeof(RampApi)) return _rampsByName as Dictionary; @@ -318,11 +329,12 @@ private Dictionary GetComponentDictionary(Type t) where T : if (t == typeof(DropTargetBankApi)) return _dropTargetBanksByComponent as Dictionary; if (t == typeof(FlipperApi)) return _flippersByComponent as Dictionary; if (t == typeof(GateApi)) return _gatesByComponent as Dictionary; - if (t == typeof(HitTargetApi)) return _hitTargetsByComponent as Dictionary; - if (t == typeof(KickerApi)) return _kickersByComponent as Dictionary; - if (t == typeof(LightApi)) return _lightsByComponent as Dictionary; - if (t == typeof(LightGroupApi)) return _lightGroupsByComponent as Dictionary; - if (t == typeof(PlungerApi)) return _plungersByComponent as Dictionary; + if (t == typeof(HitTargetApi)) return _hitTargetsByComponent as Dictionary; + if (t == typeof(KickerApi)) return _kickersByComponent as Dictionary; + if (t == typeof(LightApi)) return _lightsByComponent as Dictionary; + if (t == typeof(LightGroupApi)) return _lightGroupsByComponent as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByComponent as Dictionary; + if (t == typeof(PlungerApi)) return _plungersByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; if (t == typeof(RampApi)) return _rampsByComponent as Dictionary; From 1b755139ce9608939e747e43dadb71ccbdfbd297 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 18:34:40 +0200 Subject: [PATCH 02/50] physics(magnets): add center grab hold Clamp grabbed balls to the magnet core in VPX-compatible mode, emit grab and release transitions, drive the ball-held switch, and make ReleaseBall suppress immediate recapture until the ball leaves the grab radius. --- .../Physics/MagnetPhysicsTests.cs | 22 +++++ .../VisualPinball.Unity/Game/InsideOfs.cs | 16 +++- .../VPT/Magnet/MagnetApi.cs | 36 +++++-- .../VPT/Magnet/MagnetComponent.cs | 5 +- .../VPT/Magnet/MagnetPhysics.cs | 95 ++++++++++++++++++- .../VPT/Magnet/MagnetState.cs | 3 +- 6 files changed, 161 insertions(+), 16 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 71244d137..baca8a87d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -63,6 +63,28 @@ public void PlanarDampingUsesFrameFractionExponent() Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); } + [Test] + public void VpxCompatibleGrabClampsBallToMagnetCenter() + { + var ball = CreateBall(); + ball.EventPosition = new float3(49f, -2f, 10f); + ball.Velocity = new float3(3f, -4f, 5f); + ball.OldVelocity = new float3(2f, 1f, -1f); + ball.AngularMomentum = new float3(1f, 2f, 3f); + var magnet = new MagnetState { + Position = new float2(12f, -8f) + }; + + MagnetPhysics.ApplyVpxCompatibleGrab(ref ball, in magnet); + + Assert.That(ball.Position.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Position.z, Is.EqualTo(10f)); + Assert.That(ball.EventPosition.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Velocity, Is.EqualTo(new float3(0f, 0f, 5f))); + Assert.That(ball.OldVelocity, Is.EqualTo(new float3(0f, 0f, -1f))); + Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs index a86ff0cb3..d21136ca3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/InsideOfs.cs @@ -71,9 +71,13 @@ internal bool IsInsideOf(int itemId, int ballId) return _insideOfs.TryGetValue(itemId, out var bits) && bits.IsSet(GetBitIndex(ballId)); } - internal bool IsOutsideOf(int itemId, int ballId) => !IsInsideOf(itemId, ballId); - - internal int GetInsideCount(int itemId) + internal bool IsOutsideOf(int itemId, int ballId) => !IsInsideOf(itemId, ballId); + + internal int GetOrCreateBitIndex(int ballId) => GetBitIndex(ballId); + + internal bool TryGetBitIndex(int ballId, out int bitIndex) => _bitLookup.TryGetValue(ballId, out bitIndex); + + internal int GetInsideCount(int itemId) { if (!_insideOfs.TryGetValue(itemId, out var bits)) { return 0; @@ -156,7 +160,9 @@ private int GetBitIndex(int ballId) return newIndex; } - private bool TryGetBallId(int bitIndex, out int ballId) + internal bool TryGetBallIdAtBitIndex(int bitIndex, out int ballId) => TryGetBallId(bitIndex, out ballId); + + private bool TryGetBallId(int bitIndex, out int ballId) { foreach (var kvp in _bitLookup) { if (kvp.Value == bitIndex) { @@ -174,4 +180,4 @@ public void Dispose() _insideOfs.Dispose(); } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 668ae331c..7e6a8bfe1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -15,16 +15,14 @@ // along with this program. If not, see . using System; -using NLog; +using System.Collections.Generic; using UnityEngine; -using Logger = NLog.Logger; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiMagnetEvents { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private readonly MagnetComponent _component; private readonly Player _player; private readonly PhysicsEngine _physicsEngine; @@ -33,6 +31,7 @@ public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiMagnetEvent private DeviceCoil _magnetCoil; private DeviceSwitch _ballHeldSwitch; private bool _isEnabled; + private readonly HashSet _heldBalls = new(); public event EventHandler Init; public event EventHandler BallEntered; @@ -69,7 +68,16 @@ public float Radius { public void ReleaseBall() { - Logger.Debug($"ReleaseBall called on magnet {_component.name}; grab support is enabled in the grab/hold milestone."); + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(_itemId)) { + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + MagnetPhysics.ReleaseGrabbedBalls(_itemId, ref magnet, ref state, true); + }); } void IApi.OnInit(BallManager ballManager) @@ -132,8 +140,22 @@ private void SetEnabledSimThread(bool enabled) void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new BallEventArgs(ballId)); void IApiMagnetEvents.OnMagnetBallExited(int ballId) => BallExited?.Invoke(this, new BallEventArgs(ballId)); - void IApiMagnetEvents.OnMagnetBallGrabbed(int ballId) => BallGrabbed?.Invoke(this, new BallEventArgs(ballId)); - void IApiMagnetEvents.OnMagnetBallReleased(int ballId) => BallReleased?.Invoke(this, new BallEventArgs(ballId)); + + void IApiMagnetEvents.OnMagnetBallGrabbed(int ballId) + { + _heldBalls.Add(ballId); + _ballHeldSwitch.SetSwitch(true); + BallGrabbed?.Invoke(this, new BallEventArgs(ballId)); + } + + void IApiMagnetEvents.OnMagnetBallReleased(int ballId) + { + _heldBalls.Remove(ballId); + if (_heldBalls.Count == 0) { + _ballHeldSwitch.SetSwitch(false); + } + BallReleased?.Invoke(this, new BallEventArgs(ballId)); + } } internal interface IApiMagnetEvents diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index d934ef586..7773e01c4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -116,7 +116,7 @@ private void Awake() internal MagnetState CreateState() { - var pos = transform.localPosition.TranslateToVpx(); + var pos = (float3)transform.localPosition.TranslateToVpx(); return new MagnetState { Position = pos.xy, Height = pos.z, @@ -127,7 +127,8 @@ internal MagnetState CreateState() IsEnabled = IsEnabledOnStart, Profile = ForceProfile, HeightRange = MillimetersToVpx(HeightRange), - GrabbedBalls = default + GrabbedBalls = default, + ReleasedBalls = default }; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index ffbf421f2..b6d51f84c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -30,6 +30,7 @@ internal static class MagnetPhysics internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) { if (!magnet.IsEnabled) { + ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); ReleaseMembership(itemId, ref state); return; } @@ -38,13 +39,21 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; if (ball.IsFrozen) { + ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); UpdateMembership(itemId, ball.Id, false, ref state); continue; } var affectsBall = IsBallInRange(in ball, in magnet); - UpdateMembership(itemId, ball.Id, affectsBall, ref state); if (!affectsBall) { + ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); + ClearReleasedBall(ref magnet, ref state, ball.Id); + UpdateMembership(itemId, ball.Id, false, ref state); + continue; + } + + UpdateMembership(itemId, ball.Id, true, ref state); + if (UpdateGrab(itemId, ref magnet, ref state, ref ball)) { continue; } @@ -58,6 +67,26 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState } } + internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) + { + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + continue; + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + if (suppressRegrab) { + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + } + + if (!suppressRegrab) { + magnet.ReleasedBalls = default; + } + } + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) { var delta = ball.Position.xy - magnet.Position; @@ -75,6 +104,15 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet) + { + ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); + ball.EventPosition = new float3(magnet.Position.x, magnet.Position.y, ball.EventPosition.z); + ball.Velocity = new float3(0f, 0f, ball.Velocity.z); + ball.OldVelocity = new float3(0f, 0f, ball.OldVelocity.z); + ball.AngularMomentum = float3.zero; + } + internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) { if (magnet.Radius <= 0f) { @@ -86,6 +124,61 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } + private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball) + { + var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); + var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); + var isInGrabRange = magnet.GrabRadius > 0f && + magnet.Strength > 0f && + math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.GrabRadius * magnet.GrabRadius; + + if (!isInGrabRange) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + if (isGrabbed) { + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ball.Id, ref state, false); + } + return false; + } + + if (magnet.ReleasedBalls.IsSet(bitIndex)) { + return false; + } + + if (!isGrabbed) { + magnet.GrabbedBalls.SetBits(bitIndex, true); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); + } + ApplyVpxCompatibleGrab(ref ball, in magnet); + return true; + } + + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) + { + if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + return; + } + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, false); + } + + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) + { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + return; + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + if (suppressRegrab) { + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + + private static void ClearReleasedBall(ref MagnetState magnet, ref PhysicsState state, int ballId) + { + if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + } + private static void ReleaseMembership(int itemId, ref PhysicsState state) { var ballIds = state.InsideOfs.GetIdsOfBallsInsideItem(itemId); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 709bc6cfb..a56b19ed5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -14,8 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using Unity.Collections; using Unity.Mathematics; -using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -31,5 +31,6 @@ internal struct MagnetState internal MagnetForceProfile Profile; internal float HeightRange; internal BitField64 GrabbedBalls; + internal BitField64 ReleasedBalls; } } From 0f3bc37c0125231bca1c7916a4dfbbe2188b4f07 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 18:39:56 +0200 Subject: [PATCH 03/50] physics(magnets): support runtime repel Route Strength and Radius setters through the physics state, keep negative strength as first-class repulsion, and add an eject helper that releases grabbed balls with a directed kick. --- .../Physics/MagnetPhysicsTests.cs | 36 ++++++++++++++++ .../VPT/Magnet/MagnetApi.cs | 42 ++++++++++++++++++- .../VPT/Magnet/MagnetPhysics.cs | 31 ++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index baca8a87d..0736c45a7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -63,6 +63,23 @@ public void PlanarDampingUsesFrameFractionExponent() Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); } + [Test] + public void VpxCompatibleForceRepelsWithNegativeStrength() + { + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = -10f, + PlanarDamping = 1f + }; + + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.GreaterThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + [Test] public void VpxCompatibleGrabClampsBallToMagnetCenter() { @@ -85,6 +102,25 @@ public void VpxCompatibleGrabClampsBallToMagnetCenter() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void PlanarEjectUsesKickerAngleConvention() + { + var ball = CreateBall(); + ball.Velocity = new float3(0f, 0f, 5f); + ball.OldVelocity = new float3(0f, 0f, -1f); + ball.AngularMomentum = new float3(1f, 2f, 3f); + + MagnetPhysics.ApplyPlanarEject(ref ball, 20f, 90f); + + Assert.That(ball.Velocity.x, Is.EqualTo(20f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + Assert.That(ball.OldVelocity.x, Is.EqualTo(20f).Within(1e-5f)); + Assert.That(ball.OldVelocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.OldVelocity.z, Is.EqualTo(-1f).Within(1e-5f)); + Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 7e6a8bfe1..6aaa1f16d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -58,12 +58,36 @@ public bool IsEnabled { public float Strength { get => _component.Strength; - set => _component.Strength = value; + set { + _component.Strength = value; + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.Strength = value; + } + }); + } } public float Radius { get => _component.Radius; - set => _component.Radius = value; + set { + var radius = Mathf.Max(0f, value); + _component.Radius = radius; + if (!_physicsEngine) { + return; + } + var radiusVpx = MagnetComponent.MillimetersToVpx(radius); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.Radius = radiusVpx; + } + }); + } } public void ReleaseBall() @@ -80,6 +104,20 @@ public void ReleaseBall() }); } + public void Eject(float speed, float angleDeg) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(_itemId)) { + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + MagnetPhysics.EjectGrabbedBalls(_itemId, ref magnet, ref state, speed, angleDeg); + }); + } + void IApi.OnInit(BallManager ballManager) { SetEnabled(_component.IsEnabledOnStart); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index b6d51f84c..98501bcbf 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -17,6 +17,7 @@ using Unity.Burst; using Unity.Mathematics; using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -87,6 +88,24 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref } } + internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, float speed, float angleDeg) + { + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + continue; + } + if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + if (state.Balls.ContainsKey(ballId)) { + ref var ball = ref state.Balls.GetValueByRef(ballId); + ApplyPlanarEject(ref ball, speed, angleDeg); + } + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + } + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) { var delta = ball.Position.xy - magnet.Position; @@ -113,6 +132,18 @@ internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState m ball.AngularMomentum = float3.zero; } + internal static void ApplyPlanarEject(ref BallState ball, float speed, float angleDeg) + { + var angleRad = math.radians(angleDeg); + var velocity = new float2( + math.sin(angleRad) * speed, + -math.cos(angleRad) * speed + ); + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + ball.OldVelocity = new float3(velocity.x, velocity.y, ball.OldVelocity.z); + ball.AngularMomentum = float3.zero; + } + internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) { if (magnet.Radius <= 0f) { From 800ea04cad2cab9faae1e3ee66c4aa70fa6cf28d Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 18:46:16 +0200 Subject: [PATCH 04/50] editor(magnets): detect script magnets Add a review-first editor tool that scans VPX table scripts for cvpmMagnet patterns, creates selected MagnetComponent instances from trigger data, maps solenoid coils, and flags turntable or manual-review cases. --- .../VPT/Magnet/MagnetDetectionWindow.cs | 539 ++++++++++++++++++ .../VPT/Magnet/MagnetDetectionWindow.cs.meta | 11 + 2 files changed, 550 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs new file mode 100644 index 000000000..d47dcf333 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs @@ -0,0 +1,539 @@ +// 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 System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Unity.Mathematics; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using VisualPinball.Engine.Math; +using Object = UnityEngine.Object; + +namespace VisualPinball.Unity.Editor +{ + public class MagnetDetectionWindow : EditorWindow + { + private const string WindowTitle = "Detect Magnets"; + private static readonly Regex NewDeviceRegex = new(@"^\s*Set\s+(?[A-Za-z_]\w*)\s*=\s*New\s+(?cvpmMagnet|cvpmTurntable)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex WithRegex = new(@"^\s*With\s+(?[A-Za-z_]\w*)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex EndWithRegex = new(@"^\s*End\s+With\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex InitMagnetRegex = new(@"(?[A-Za-z_]\w*)\.InitMagnet\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex InitTurntableRegex = new(@"(?[A-Za-z_]\w*)\.InitTurnTable\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex GrabCenterRegex = new(@"(?[A-Za-z_]\w*)\.GrabCenter\s*=\s*(?True|False|0|1)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SolenoidRegex = new(@"(?[A-Za-z_]\w*)\.Solenoid\s*=\s*(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SolCallbackRegex = new(@"SolCallback\s*\(\s*(?[^)]+)\s*\)\s*=\s*""(?[A-Za-z_]\w*)\.(?MagnetOn|MotorOn|SpinCW)\s*=", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex CreateEventsRegex = new(@"(?[A-Za-z_]\w*)\.CreateEvents\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex BallTrackingRegex = new(@"(?[A-Za-z_]\w*)\.(AddBall|RemoveBall)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex StrengthAssignmentRegex = new(@"(?[A-Za-z_]\w*)\.Strength\s*=", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + [SerializeField] private TableComponent _tableComponent; + [SerializeField] private Object _scriptAsset; + [SerializeField] private string _scriptPath = string.Empty; + + private readonly List _candidates = new(); + private Vector2 _scroll; + private string _status = string.Empty; + + [MenuItem("Pinball/Tools/Detect Magnets", false, 412)] + public static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent(WindowTitle); + window.TryUseSelectedTable(); + window.Show(); + } + + private void OnEnable() + { + titleContent = new GUIContent(WindowTitle); + if (!_tableComponent) { + TryUseSelectedTable(); + } + } + + private void OnGUI() + { + EditorGUILayout.Space(6f); + _tableComponent = (TableComponent)EditorGUILayout.ObjectField("Table", _tableComponent, typeof(TableComponent), true); + _scriptAsset = EditorGUILayout.ObjectField("Script Asset", _scriptAsset, typeof(Object), false); + + using (new EditorGUILayout.HorizontalScope()) { + _scriptPath = EditorGUILayout.TextField("Script Path", _scriptPath); + if (GUILayout.Button("Browse", GUILayout.Width(72))) { + var path = EditorUtility.OpenFilePanel("Select VPX Table Script", Application.dataPath, "vbs"); + if (!string.IsNullOrEmpty(path)) { + _scriptPath = path; + _scriptAsset = null; + } + } + } + + using (new EditorGUILayout.HorizontalScope()) { + if (GUILayout.Button("Use Selection", GUILayout.Width(110))) { + TryUseSelectedTable(); + } + if (GUILayout.Button("Scan", GUILayout.Width(80))) { + Scan(); + } + using (new EditorGUI.DisabledScope(_candidates.All(c => !c.Selected || !c.CanCreate))) { + if (GUILayout.Button("Create Selected", GUILayout.Width(120))) { + CreateSelected(); + } + } + } + + if (!string.IsNullOrEmpty(_status)) { + EditorGUILayout.HelpBox(_status, MessageType.Info); + } + + EditorGUILayout.Space(6f); + _scroll = EditorGUILayout.BeginScrollView(_scroll); + foreach (var candidate in _candidates) { + DrawCandidate(candidate); + } + EditorGUILayout.EndScrollView(); + } + + private void DrawCandidate(DetectionCandidate candidate) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { + using (new EditorGUILayout.HorizontalScope()) { + candidate.Selected = EditorGUILayout.Toggle(candidate.Selected, GUILayout.Width(18)); + EditorGUILayout.LabelField($"{candidate.TypeLabel}: {candidate.Name}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"line {candidate.Line}", GUILayout.Width(62)); + } + + EditorGUILayout.LabelField("Trigger", candidate.TriggerName); + EditorGUILayout.LabelField("Strength", candidate.Strength.ToString(CultureInfo.InvariantCulture)); + EditorGUILayout.LabelField("Grab", candidate.GrabBall ? "Yes" : "No"); + EditorGUILayout.LabelField("Coil", string.IsNullOrEmpty(candidate.CoilId) ? "None" : candidate.CoilId); + + foreach (var note in candidate.Notes) { + EditorGUILayout.HelpBox(note, MessageType.Warning); + } + if (!candidate.CanCreate) { + EditorGUILayout.HelpBox(candidate.BlockReason, MessageType.Info); + } + } + } + + private void Scan() + { + _candidates.Clear(); + _status = string.Empty; + + if (!_tableComponent) { + _status = "Select a table before scanning."; + return; + } + + if (!TryReadScript(out var script, out var error)) { + _status = error; + return; + } + + _candidates.AddRange(Parse(script)); + foreach (var candidate in _candidates) { + ResolveCandidate(candidate); + } + _status = _candidates.Count == 0 + ? "No cvpmMagnet or cvpmTurntable instances found." + : $"Found {_candidates.Count} magnet-related script candidate(s)."; + } + + private void ResolveCandidate(DetectionCandidate candidate) + { + if (candidate.Kind == DetectionKind.Turntable) { + candidate.Selected = false; + candidate.BlockReason = "Turntable candidates are listed for review and can be created after TurntableComponent is available."; + return; + } + + candidate.Trigger = FindTrigger(candidate.TriggerName); + if (!candidate.Trigger) { + candidate.Selected = false; + candidate.BlockReason = $"Trigger \"{candidate.TriggerName}\" was not found in the selected table."; + } + } + + private void CreateSelected() + { + var created = new List(); + Undo.IncrementCurrentGroup(); + Undo.SetCurrentGroupName("Create detected magnets"); + var undoGroup = Undo.GetCurrentGroup(); + Undo.RecordObject(_tableComponent, "Create detected magnets"); + + foreach (var candidate in _candidates.Where(c => c.Selected && c.CanCreate)) { + var magnet = CreateMagnet(candidate); + if (magnet) { + created.Add(magnet.gameObject); + } + } + + EditorUtility.SetDirty(_tableComponent); + EditorSceneManager.MarkSceneDirty(_tableComponent.gameObject.scene); + Undo.CollapseUndoOperations(undoGroup); + + if (created.Count > 0) { + Selection.objects = created.ToArray(); + } + _status = $"Created {created.Count} magnet(s)."; + } + + private MagnetComponent CreateMagnet(DetectionCandidate candidate) + { + var trigger = candidate.Trigger; + if (!trigger) { + return null; + } + + var parent = trigger.transform.parent; + var name = GameObjectUtility.GetUniqueNameForSibling(parent, candidate.Name); + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, "Create detected magnet"); + go.transform.SetParent(parent, false); + go.transform.localPosition = trigger.transform.localPosition; + go.transform.localRotation = trigger.transform.localRotation; + go.transform.localScale = Vector3.one; + + var magnet = Undo.AddComponent(go); + Undo.RecordObject(magnet, "Configure detected magnet"); + magnet.Radius = VpxRadiusToMillimeters(GetTriggerRadius(trigger)); + magnet.Strength = candidate.Strength; + magnet.ForceProfile = MagnetForceProfile.VpxCompatible; + magnet.GrabBall = candidate.GrabBall; + magnet.IsEnabledOnStart = false; + + if (!string.IsNullOrEmpty(candidate.CoilId) && !HasCoilMapping(candidate.CoilId, magnet)) { + _tableComponent.MappingConfig.AddCoil(new CoilMapping { + Id = candidate.CoilId, + Description = $"{candidate.Name} magnet", + Destination = CoilDestination.Playfield, + Device = magnet, + DeviceItem = MagnetComponent.MagnetCoilItem + }); + } + + EditorUtility.SetDirty(magnet); + return magnet; + } + + private bool HasCoilMapping(string coilId, MagnetComponent magnet) + { + return _tableComponent.MappingConfig.Coils.Any(mapping => + string.Equals(mapping.Id, coilId, StringComparison.OrdinalIgnoreCase) && + mapping.Device is MagnetComponent mappedMagnet && + mappedMagnet == magnet && + mapping.DeviceItem == MagnetComponent.MagnetCoilItem); + } + + private TriggerComponent FindTrigger(string triggerName) + { + return _tableComponent.GetComponentsInChildren(true) + .FirstOrDefault(trigger => string.Equals(trigger.name, triggerName, StringComparison.OrdinalIgnoreCase)); + } + + private static float GetTriggerRadius(TriggerComponent trigger) + { + var collider = trigger.GetComponentInChildren(true); + if (collider && collider.HitCircleRadius > 0f) { + return collider.HitCircleRadius; + } + + var maxRadius = 25f; + foreach (var dragPoint in trigger.DragPoints ?? Array.Empty()) { + maxRadius = math.max(maxRadius, math.length(new float2(dragPoint.Center.X, dragPoint.Center.Y))); + } + return maxRadius; + } + + private static float VpxRadiusToMillimeters(float radiusVpx) + => VisualPinball.Unity.Physics.ScaleToWorld(radiusVpx) / MagnetComponent.MillimetersToWorld; + + private bool TryReadScript(out string script, out string error) + { + var path = ResolveScriptPath(); + if (string.IsNullOrEmpty(path)) { + script = string.Empty; + error = "Select or browse to a VPX table script."; + return false; + } + if (!File.Exists(path)) { + script = string.Empty; + error = $"Script file does not exist: {path}"; + return false; + } + script = File.ReadAllText(path); + error = string.Empty; + return true; + } + + private string ResolveScriptPath() + { + if (_scriptAsset) { + var assetPath = AssetDatabase.GetAssetPath(_scriptAsset); + if (!string.IsNullOrEmpty(assetPath)) { + _scriptPath = Path.GetFullPath(assetPath); + } + } + return _scriptPath; + } + + private void TryUseSelectedTable() + { + if (!Selection.activeGameObject) { + return; + } + _tableComponent = Selection.activeGameObject.GetComponentInParent(); + } + + private static IEnumerable Parse(string script) + { + var devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + string withTarget = null; + var lines = script.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + for (var i = 0; i < lines.Length; i++) { + var lineNumber = i + 1; + var line = StripComment(lines[i]).Trim(); + if (line.Length == 0) { + continue; + } + + var withMatch = WithRegex.Match(line); + if (withMatch.Success) { + withTarget = withMatch.Groups["name"].Value; + continue; + } + if (EndWithRegex.IsMatch(line)) { + withTarget = null; + continue; + } + if (withTarget != null && line.StartsWith(".", StringComparison.Ordinal)) { + line = withTarget + line; + } + + ReadNewDevice(line, lineNumber, devices); + ReadInit(line, lineNumber, devices); + ReadGrab(line, devices); + ReadSolenoid(line, lineNumber, devices); + ReadCreateEvents(line, devices); + ReadBallTracking(line, lineNumber, devices); + ReadStrengthMutation(line, lineNumber, devices); + } + + return devices.Values + .Where(device => device.HasInit) + .Select(device => device.ToCandidate()) + .OrderBy(candidate => candidate.Line) + .ToList(); + } + + private static void ReadNewDevice(string line, int lineNumber, Dictionary devices) + { + var match = NewDeviceRegex.Match(line); + if (!match.Success) { + return; + } + var device = GetDevice(devices, match.Groups["name"].Value); + device.Kind = ParseKind(match.Groups["type"].Value); + device.Line = lineNumber; + } + + private static void ReadInit(string line, int lineNumber, Dictionary devices) + { + var magnet = InitMagnetRegex.Match(line); + if (magnet.Success) { + var device = GetDevice(devices, magnet.Groups["name"].Value); + device.Kind = DetectionKind.Magnet; + device.TriggerName = magnet.Groups["trigger"].Value; + device.Strength = ParseFloat(magnet.Groups["strength"].Value); + device.Line = device.Line == 0 ? lineNumber : device.Line; + device.HasInit = true; + return; + } + + var turntable = InitTurntableRegex.Match(line); + if (turntable.Success) { + var device = GetDevice(devices, turntable.Groups["name"].Value); + device.Kind = DetectionKind.Turntable; + device.TriggerName = turntable.Groups["trigger"].Value; + device.Strength = ParseFloat(turntable.Groups["strength"].Value); + device.Line = device.Line == 0 ? lineNumber : device.Line; + device.HasInit = true; + } + } + + private static void ReadGrab(string line, Dictionary devices) + { + var match = GrabCenterRegex.Match(line); + if (match.Success) { + var device = GetDevice(devices, match.Groups["name"].Value); + device.GrabCenter = IsTruthy(match.Groups["value"].Value); + device.HasExplicitGrab = true; + } + } + + private static void ReadSolenoid(string line, int lineNumber, Dictionary devices) + { + var solenoid = SolenoidRegex.Match(line); + if (solenoid.Success) { + var device = GetDevice(devices, solenoid.Groups["name"].Value); + device.CoilId = solenoid.Groups["coil"].Value; + return; + } + + var callback = SolCallbackRegex.Match(line); + if (!callback.Success) { + return; + } + + var callbackDevice = GetDevice(devices, callback.Groups["name"].Value); + var coil = callback.Groups["coil"].Value.Trim(); + if (int.TryParse(coil, NumberStyles.Integer, CultureInfo.InvariantCulture, out var coilId)) { + callbackDevice.CoilId = coilId.ToString(CultureInfo.InvariantCulture); + } else { + callbackDevice.Notes.Add($"Line {lineNumber}: solenoid callback uses expression \"{coil}\"; map the coil manually."); + } + } + + private static void ReadCreateEvents(string line, Dictionary devices) + { + var match = CreateEventsRegex.Match(line); + if (match.Success) { + GetDevice(devices, match.Groups["name"].Value).HasCreateEvents = true; + } + } + + private static void ReadBallTracking(string line, int lineNumber, Dictionary devices) + { + var match = BallTrackingRegex.Match(line); + if (match.Success) { + GetDevice(devices, match.Groups["name"].Value).ManualBallLines.Add(lineNumber); + } + } + + private static void ReadStrengthMutation(string line, int lineNumber, Dictionary devices) + { + var match = StrengthAssignmentRegex.Match(line); + if (match.Success && line.IndexOf("InitMagnet", StringComparison.OrdinalIgnoreCase) < 0) { + GetDevice(devices, match.Groups["name"].Value).StrengthMutationLines.Add(lineNumber); + } + } + + private static ParsedDevice GetDevice(Dictionary devices, string name) + { + if (!devices.TryGetValue(name, out var device)) { + device = new ParsedDevice { Name = name }; + devices.Add(name, device); + } + return device; + } + + private static DetectionKind ParseKind(string type) + => type.Equals("cvpmTurntable", StringComparison.OrdinalIgnoreCase) ? DetectionKind.Turntable : DetectionKind.Magnet; + + private static float ParseFloat(string value) + => float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) ? result : 0f; + + private static bool IsTruthy(string value) + => value.Equals("True", StringComparison.OrdinalIgnoreCase) || value == "1"; + + private static string StripComment(string line) + { + var inString = false; + for (var i = 0; i < line.Length; i++) { + if (line[i] == '"') { + inString = !inString; + } else if (line[i] == '\'' && !inString) { + return line.Substring(0, i); + } + } + return line; + } + + private enum DetectionKind + { + Magnet, + Turntable + } + + private sealed class ParsedDevice + { + public string Name; + public DetectionKind Kind; + public string TriggerName = string.Empty; + public float Strength; + public int Line; + public bool HasInit; + public bool HasCreateEvents; + public bool HasExplicitGrab; + public bool GrabCenter; + public string CoilId = string.Empty; + public readonly List ManualBallLines = new(); + public readonly List StrengthMutationLines = new(); + public readonly List Notes = new(); + + public DetectionCandidate ToCandidate() + { + var candidate = new DetectionCandidate { + Name = Name, + Kind = Kind, + TriggerName = TriggerName, + Strength = Strength, + Line = Line, + GrabBall = Kind == DetectionKind.Magnet && (HasExplicitGrab ? GrabCenter : Strength > 14f), + CoilId = CoilId + }; + candidate.Notes.AddRange(Notes); + if (ManualBallLines.Count > 0 && !HasCreateEvents) { + candidate.Notes.Add($"Manual AddBall/RemoveBall calls at line(s) {string.Join(", ", ManualBallLines)}; verify membership behavior."); + } + if (StrengthMutationLines.Count > 0) { + candidate.Notes.Add($"Runtime Strength assignment at line(s) {string.Join(", ", StrengthMutationLines)}; port the script-side modulation."); + } + if (string.IsNullOrEmpty(CoilId)) { + candidate.Notes.Add("No solenoid mapping found."); + } + return candidate; + } + } + + private sealed class DetectionCandidate + { + public bool Selected = true; + public string Name; + public DetectionKind Kind; + public string TriggerName; + public float Strength; + public bool GrabBall; + public string CoilId; + public int Line; + public TriggerComponent Trigger; + public string BlockReason = string.Empty; + public readonly List Notes = new(); + + public bool CanCreate => Kind == DetectionKind.Magnet && Trigger; + public string TypeLabel => Kind == DetectionKind.Magnet ? "Magnet" : "Turntable"; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta new file mode 100644 index 000000000..f40ad9964 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3337cbede1b743699201e6d49faf8807 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 3579adfb405dce97e5e225b77724adbf3738bbc7 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 18:54:25 +0200 Subject: [PATCH 05/50] physics(turntables): add spinning disc device Add TurntableComponent with native physics state, motor and direction coils, VPX-compatible tangential force, visual rotation support, detector creation, editor inspector, and focused physics coverage. --- .../VPT/Magnet.meta | 2 +- .../VPT/Magnet/MagnetDetectionWindow.cs | 107 +++++++++- .../VPT/Magnet/MagnetInspector.cs.meta | 2 +- .../VPT/Turntable.meta | 8 + .../VPT/Turntable/TurntableInspector.cs | 67 ++++++ .../VPT/Turntable/TurntableInspector.cs.meta | 11 + .../Physics/TurntablePhysicsTests.cs | 86 ++++++++ .../Physics/TurntablePhysicsTests.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsEngine.cs | 6 + .../Game/PhysicsEngineContext.cs | 4 +- .../VisualPinball.Unity/Game/PhysicsState.cs | 17 +- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 7 + .../VPT/Magnet/MagnetPackable.cs.meta | 2 +- .../VPT/Magnet/MagnetPhysics.cs.meta | 2 +- .../VisualPinball.Unity/VPT/Turntable.meta | 8 + .../VPT/Turntable/TurntableApi.cs | 190 ++++++++++++++++++ .../VPT/Turntable/TurntableApi.cs.meta | 11 + .../VPT/Turntable/TurntableComponent.cs | 148 ++++++++++++++ .../VPT/Turntable/TurntableComponent.cs.meta | 11 + .../VPT/Turntable/TurntablePackable.cs | 51 +++++ .../VPT/Turntable/TurntablePackable.cs.meta | 11 + .../VPT/Turntable/TurntablePhysics.cs | 80 ++++++++ .../VPT/Turntable/TurntablePhysics.cs.meta | 11 + .../VPT/Turntable/TurntableState.cs | 34 ++++ .../VPT/Turntable/TurntableState.cs.meta | 11 + 25 files changed, 876 insertions(+), 22 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta index efe3a4080..bbd71f873 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0f7f581e7f1f25a4b8308ac0540b71ce +guid: a2e6af63f212a8c48993172eb0074345 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs index d47dcf333..c3417c065 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs @@ -124,7 +124,12 @@ private void DrawCandidate(DetectionCandidate candidate) EditorGUILayout.LabelField("Trigger", candidate.TriggerName); EditorGUILayout.LabelField("Strength", candidate.Strength.ToString(CultureInfo.InvariantCulture)); EditorGUILayout.LabelField("Grab", candidate.GrabBall ? "Yes" : "No"); - EditorGUILayout.LabelField("Coil", string.IsNullOrEmpty(candidate.CoilId) ? "None" : candidate.CoilId); + if (candidate.Kind == DetectionKind.Magnet) { + EditorGUILayout.LabelField("Coil", string.IsNullOrEmpty(candidate.CoilId) ? "None" : candidate.CoilId); + } else { + EditorGUILayout.LabelField("Motor Coil", string.IsNullOrEmpty(candidate.MotorCoilId) ? "None" : candidate.MotorCoilId); + EditorGUILayout.LabelField("Direction Coil", string.IsNullOrEmpty(candidate.DirectionCoilId) ? "None" : candidate.DirectionCoilId); + } foreach (var note in candidate.Notes) { EditorGUILayout.HelpBox(note, MessageType.Warning); @@ -162,8 +167,11 @@ private void Scan() private void ResolveCandidate(DetectionCandidate candidate) { if (candidate.Kind == DetectionKind.Turntable) { - candidate.Selected = false; - candidate.BlockReason = "Turntable candidates are listed for review and can be created after TurntableComponent is available."; + candidate.Trigger = FindTrigger(candidate.TriggerName); + if (!candidate.Trigger) { + candidate.Selected = false; + candidate.BlockReason = $"Trigger \"{candidate.TriggerName}\" was not found in the selected table."; + } return; } @@ -183,9 +191,11 @@ private void CreateSelected() Undo.RecordObject(_tableComponent, "Create detected magnets"); foreach (var candidate in _candidates.Where(c => c.Selected && c.CanCreate)) { - var magnet = CreateMagnet(candidate); - if (magnet) { - created.Add(magnet.gameObject); + var createdObject = candidate.Kind == DetectionKind.Turntable + ? CreateTurntable(candidate)?.gameObject + : CreateMagnet(candidate)?.gameObject; + if (createdObject) { + created.Add(createdObject); } } @@ -237,6 +247,50 @@ private MagnetComponent CreateMagnet(DetectionCandidate candidate) return magnet; } + private TurntableComponent CreateTurntable(DetectionCandidate candidate) + { + var trigger = candidate.Trigger; + if (!trigger) { + return null; + } + + var parent = trigger.transform.parent; + var name = GameObjectUtility.GetUniqueNameForSibling(parent, candidate.Name); + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, "Create detected turntable"); + go.transform.SetParent(parent, false); + go.transform.localPosition = trigger.transform.localPosition; + go.transform.localRotation = trigger.transform.localRotation; + go.transform.localScale = Vector3.one; + + var turntable = Undo.AddComponent(go); + Undo.RecordObject(turntable, "Configure detected turntable"); + turntable.Radius = VpxRadiusToMillimeters(GetTriggerRadius(trigger)); + turntable.MaxSpeed = candidate.Strength; + turntable.MotorOnStart = false; + turntable.SpinClockwise = true; + + AddTurntableCoilMapping(candidate.MotorCoilId, turntable, TurntableComponent.MotorCoilItem, $"{candidate.Name} motor"); + AddTurntableCoilMapping(candidate.DirectionCoilId, turntable, TurntableComponent.DirectionCoilItem, $"{candidate.Name} direction"); + + EditorUtility.SetDirty(turntable); + return turntable; + } + + private void AddTurntableCoilMapping(string coilId, TurntableComponent turntable, string deviceItem, string description) + { + if (string.IsNullOrEmpty(coilId) || HasCoilMapping(coilId, turntable, deviceItem)) { + return; + } + _tableComponent.MappingConfig.AddCoil(new CoilMapping { + Id = coilId, + Description = description, + Destination = CoilDestination.Playfield, + Device = turntable, + DeviceItem = deviceItem + }); + } + private bool HasCoilMapping(string coilId, MagnetComponent magnet) { return _tableComponent.MappingConfig.Coils.Any(mapping => @@ -246,6 +300,15 @@ mapping.Device is MagnetComponent mappedMagnet && mapping.DeviceItem == MagnetComponent.MagnetCoilItem); } + private bool HasCoilMapping(string coilId, TurntableComponent turntable, string deviceItem) + { + return _tableComponent.MappingConfig.Coils.Any(mapping => + string.Equals(mapping.Id, coilId, StringComparison.OrdinalIgnoreCase) && + mapping.Device is TurntableComponent mappedTurntable && + mappedTurntable == turntable && + mapping.DeviceItem == deviceItem); + } + private TriggerComponent FindTrigger(string triggerName) { return _tableComponent.GetComponentsInChildren(true) @@ -410,7 +473,7 @@ private static void ReadSolenoid(string line, int lineNumber, Dictionary ManualBallLines = new(); public readonly List StrengthMutationLines = new(); public readonly List Notes = new(); + public void AssignCallbackCoil(string member, string coilId) + { + if (member.Equals("MotorOn", StringComparison.OrdinalIgnoreCase)) { + MotorCoilId = coilId; + } else if (member.Equals("SpinCW", StringComparison.OrdinalIgnoreCase)) { + DirectionCoilId = coilId; + } else { + CoilId = coilId; + } + } + public DetectionCandidate ToCandidate() { var candidate = new DetectionCandidate { @@ -502,7 +578,9 @@ public DetectionCandidate ToCandidate() Strength = Strength, Line = Line, GrabBall = Kind == DetectionKind.Magnet && (HasExplicitGrab ? GrabCenter : Strength > 14f), - CoilId = CoilId + CoilId = CoilId, + MotorCoilId = MotorCoilId, + DirectionCoilId = DirectionCoilId }; candidate.Notes.AddRange(Notes); if (ManualBallLines.Count > 0 && !HasCreateEvents) { @@ -511,8 +589,15 @@ public DetectionCandidate ToCandidate() if (StrengthMutationLines.Count > 0) { candidate.Notes.Add($"Runtime Strength assignment at line(s) {string.Join(", ", StrengthMutationLines)}; port the script-side modulation."); } - if (string.IsNullOrEmpty(CoilId)) { + if (Kind == DetectionKind.Magnet && string.IsNullOrEmpty(CoilId)) { candidate.Notes.Add("No solenoid mapping found."); + } else if (Kind == DetectionKind.Turntable) { + if (string.IsNullOrEmpty(MotorCoilId)) { + candidate.Notes.Add("No motor solenoid mapping found."); + } + if (string.IsNullOrEmpty(DirectionCoilId)) { + candidate.Notes.Add("No direction solenoid mapping found."); + } } return candidate; } @@ -527,12 +612,14 @@ private sealed class DetectionCandidate public float Strength; public bool GrabBall; public string CoilId; + public string MotorCoilId; + public string DirectionCoilId; public int Line; public TriggerComponent Trigger; public string BlockReason = string.Empty; public readonly List Notes = new(); - public bool CanCreate => Kind == DetectionKind.Magnet && Trigger; + public bool CanCreate => Trigger && (Kind == DetectionKind.Magnet || Kind == DetectionKind.Turntable); public string TypeLabel => Kind == DetectionKind.Magnet ? "Magnet" : "Turntable"; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta index ba93e9a1b..26ce28b2f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1b1e218c7a248554596d2a1cc5396ee7 +guid: 9de594893816cf748a2bdeb73de60e1f MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta new file mode 100644 index 000000000..f21618515 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cb695f1dde41412c96022c0491db4c1c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs new file mode 100644 index 000000000..0d823aaf7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -0,0 +1,67 @@ +// 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 UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(TurntableComponent))] + public class TurntableInspector : ItemInspector + { + private SerializedProperty _radiusProperty; + private SerializedProperty _maxSpeedProperty; + private SerializedProperty _spinUpProperty; + private SerializedProperty _spinDownProperty; + private SerializedProperty _motorOnStartProperty; + private SerializedProperty _spinClockwiseProperty; + private SerializedProperty _rotationTargetProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _radiusProperty = serializedObject.FindProperty(nameof(TurntableComponent.Radius)); + _maxSpeedProperty = serializedObject.FindProperty(nameof(TurntableComponent.MaxSpeed)); + _spinUpProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinUp)); + _spinDownProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinDown)); + _motorOnStartProperty = serializedObject.FindProperty(nameof(TurntableComponent.MotorOnStart)); + _spinClockwiseProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinClockwise)); + _rotationTargetProperty = serializedObject.FindProperty(nameof(TurntableComponent.RotationTarget)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + OnPreInspectorGUI(); + + PropertyField(_radiusProperty); + PropertyField(_maxSpeedProperty); + PropertyField(_spinUpProperty); + PropertyField(_spinDownProperty); + + EditorGUILayout.Space(8f); + PropertyField(_motorOnStartProperty); + PropertyField(_spinClockwiseProperty); + PropertyField(_rotationTargetProperty); + + base.OnInspectorGUI(); + EndEditing(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta new file mode 100644 index 000000000..042d71f80 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31f548565e49420cbe2adb41073e21cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs new file mode 100644 index 000000000..4526f3af7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs @@ -0,0 +1,86 @@ +// 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.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class TurntablePhysicsTests + { + [Test] + public void VpxCompatibleForceScalesToOneMillisecondTicks() + { + var oneTickBall = CreateBall(); + var tenTickBall = CreateBall(); + var turntable = new TurntableState { + Position = float2.zero, + Radius = 100f, + Speed = 80f + }; + + TurntablePhysics.ApplyVpxCompatibleForce(ref oneTickBall, in turntable, 1f); + for (var i = 0; i < 10; i++) { + TurntablePhysics.ApplyVpxCompatibleForce(ref tenTickBall, in turntable, 0.1f); + } + + Assert.That(tenTickBall.Velocity.x, Is.EqualTo(oneTickBall.Velocity.x).Within(1e-5f)); + Assert.That(tenTickBall.Velocity.y, Is.EqualTo(oneTickBall.Velocity.y).Within(1e-5f)); + } + + [Test] + public void PositiveSpeedAppliesVpxTangentialKick() + { + var ball = CreateBall(); + var turntable = new TurntableState { + Position = float2.zero, + Radius = 100f, + Speed = 80f + }; + + TurntablePhysics.ApplyVpxCompatibleForce(ref ball, in turntable, 1f); + + Assert.That(ball.Velocity.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0.5f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void SpeedRampsTowardMotorTarget() + { + var turntable = new TurntableState { + MaxSpeed = 100f, + TargetSpeed = 100f, + SpinUp = 100f, + SpinDown = 100f, + MotorOn = true + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(1f).Within(1e-5f)); + } + + private static BallState CreateBall() + { + return new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(0f, 0f, 5f) + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta new file mode 100644 index 000000000..da724abaf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51c13323bf5b4fabb3a6241ea611e7c5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 6df126fbe..b9cf9995f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -708,6 +708,11 @@ internal ref MagnetState MagnetState(int itemId) GuardLiveStateAccess(nameof(MagnetState)); return ref _ctx.MagnetStates.Ref.GetValueByRef(itemId); } + internal ref TurntableState TurntableState(int itemId) + { + GuardLiveStateAccess(nameof(TurntableState)); + return ref _ctx.TurntableStates.Ref.GetValueByRef(itemId); + } internal ref PlungerState PlungerState(int itemId) { GuardLiveStateAccess(nameof(PlungerState)); @@ -773,6 +778,7 @@ internal void Register(T item) where T : MonoBehaviour break; case SpinnerComponent c: _ctx.SpinnerStates.Ref[itemId] = c.CreateState(); break; case SurfaceComponent c: _ctx.SurfaceStates.Ref[itemId] = c.CreateState(); break; + case TurntableComponent c: _ctx.TurntableStates.Ref[itemId] = c.CreateState(); break; case TriggerComponent c: _ctx.TriggerStates.Ref[itemId] = c.CreateState(); break; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index 71d2e4ee4..99a9662f2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -114,6 +114,7 @@ internal class PhysicsEngineContext : IDisposable public readonly LazyInit> PlungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SpinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SurfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + public readonly LazyInit> TurntableStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> TriggerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> DisabledCollisionItems = new(() => new NativeParallelHashSet(0, Allocator.Persistent)); @@ -299,7 +300,7 @@ internal PhysicsState CreateState() ref NonTransformableColliderTransforms.Ref, ref KinematicColliderLookups, ref events, ref InsideOfs, ref BallStates.Ref, ref BumperStates.Ref, ref DropTargetStates.Ref, ref FlipperStates.Ref, ref GateStates.Ref, ref HitTargetStates.Ref, ref KickerStates.Ref, ref MagnetStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, - ref SurfaceStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, + ref SurfaceStates.Ref, ref TurntableStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref); } @@ -342,6 +343,7 @@ public void Dispose() PlungerStates.Ref.Dispose(); SpinnerStates.Ref.Dispose(); SurfaceStates.Ref.Dispose(); + TurntableStates.Ref.Dispose(); using (var enumerator = TriggerStates.Ref.GetEnumerator()) { while (enumerator.MoveNext()) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 87dfc4a8c..b9b474bac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -140,9 +140,10 @@ internal struct PhysicsState internal NativeParallelHashMap KickerStates; internal NativeParallelHashMap MagnetStates; internal NativeParallelHashMap PlungerStates; - internal NativeParallelHashMap SpinnerStates; - internal NativeParallelHashMap SurfaceStates; - internal NativeParallelHashMap TriggerStates; + internal NativeParallelHashMap SpinnerStates; + internal NativeParallelHashMap SurfaceStates; + internal NativeParallelHashMap TurntableStates; + internal NativeParallelHashMap TriggerStates; internal NativeParallelHashSet DisabledCollisionItems; [MarshalAs(UnmanagedType.U1)] @@ -160,7 +161,8 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, ref NativeParallelHashMap magnetStates, ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, - ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap triggerStates, + ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, + ref NativeParallelHashMap triggerStates, ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling, ref NativeParallelHashMap> elasticityOverVelocityLUTs, ref NativeParallelHashMap> frictionOverVelocityLUTs, @@ -186,9 +188,10 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native KickerStates = kickerStates; MagnetStates = magnetStates; PlungerStates = plungerStates; - SpinnerStates = spinnerStates; - SurfaceStates = surfaceStates; - TriggerStates = triggerStates; + SpinnerStates = spinnerStates; + SurfaceStates = surfaceStates; + TurntableStates = turntableStates; + TriggerStates = triggerStates; DisabledCollisionItems = disabledCollisionItems; SwapBallCollisionHandling = swapBallCollisionHandling; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 74f79b157..d62880cb9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -154,6 +154,13 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ MagnetPhysics.Update(enumerator.Current.Key, ref magnetState, ref state, physicsDiffTime); } } + // turntables + using (var enumerator = state.TurntableStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var turntableState = ref enumerator.Current.Value; + TurntablePhysics.Update(ref turntableState, ref state, physicsDiffTime); + } + } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta index 40003c53a..5c18a7303 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0e1a6d673eb8fb249a7814f7708c4684 +guid: 6bf2afc8055ef964eaad1cfb3e2d4b42 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta index 0d61f3ef8..fc9f3a2fa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 5044029e35317cd4bb2819aac55908cd +guid: ced4b8e5bf4790443948b675a0732eef MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta new file mode 100644 index 000000000..be7dead52 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 904cdaeff4724eba9a4985a3e4c310df +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs new file mode 100644 index 000000000..408855ca6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs @@ -0,0 +1,190 @@ +// 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 UnityEngine; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + public class TurntableApi : IApi, IApiCoilDevice + { + private readonly TurntableComponent _component; + private readonly Player _player; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + + private readonly DeviceCoil _motorCoil; + private readonly DeviceCoil _directionCoil; + private bool _motorOn; + private bool _spinClockwise; + + public event EventHandler Init; + + internal TurntableApi(GameObject go, Player player, PhysicsEngine physicsEngine) + { + _component = go.GetComponentInChildren(); + _player = player; + _physicsEngine = physicsEngine; + _itemId = UnityObjectId.Get(_component.gameObject); + _motorOn = _component.MotorOnStart; + _spinClockwise = _component.SpinClockwise; + + _motorCoil = new DeviceCoil(_player, OnMotorCoilEnabled, OnMotorCoilDisabled, OnMotorCoilEnabledSimThread, OnMotorCoilDisabledSimThread); + _directionCoil = new DeviceCoil(_player, OnDirectionCoilEnabled, OnDirectionCoilDisabled, OnDirectionCoilEnabledSimThread, OnDirectionCoilDisabledSimThread); + } + + public bool MotorOn { + get => _motorOn; + set => SetMotorOn(value); + } + + public bool SpinClockwise { + get => _spinClockwise; + set => SetSpinClockwise(value); + } + + public float Speed { + get { + if (!_physicsEngine) { + return 0f; + } + return _physicsEngine.TurntableState(_itemId).Speed; + } + } + + public float RotationAngle { + get { + if (!_physicsEngine) { + return 0f; + } + return _physicsEngine.TurntableState(_itemId).RotationAngle; + } + } + + public float MaxSpeed { + get => _component.MaxSpeed; + set { + _component.MaxSpeed = value; + MutateState((ref TurntableState turntable) => { + turntable.MaxSpeed = value; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + }); + } + } + + public float SpinUp { + get => _component.SpinUp; + set { + var spinUp = Mathf.Max(0f, value); + _component.SpinUp = spinUp; + MutateState((ref TurntableState turntable) => turntable.SpinUp = spinUp); + } + } + + public float SpinDown { + get => _component.SpinDown; + set { + var spinDown = Mathf.Max(0f, value); + _component.SpinDown = spinDown; + MutateState((ref TurntableState turntable) => turntable.SpinDown = spinDown); + } + } + + void IApi.OnInit(BallManager ballManager) + { + SetMotorOn(_component.MotorOnStart); + SetSpinClockwise(_component.SpinClockwise); + Init?.Invoke(this, EventArgs.Empty); + } + + void IApi.OnDestroy() + { + } + + IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + + private IApiCoil Coil(string deviceItem) + { + return deviceItem switch { + TurntableComponent.MotorCoilItem => _motorCoil, + TurntableComponent.DirectionCoilItem => _directionCoil, + _ => throw new ArgumentException($"Unknown turntable coil \"{deviceItem}\". Valid names are \"{TurntableComponent.MotorCoilItem}\" and \"{TurntableComponent.DirectionCoilItem}\".") + }; + } + + private void OnMotorCoilEnabled() => SetMotorOn(true); + private void OnMotorCoilDisabled() => SetMotorOn(false); + private void OnMotorCoilEnabledSimThread() => SetMotorOnSimThread(true); + private void OnMotorCoilDisabledSimThread() => SetMotorOnSimThread(false); + private void OnDirectionCoilEnabled() => SetSpinClockwise(true); + private void OnDirectionCoilDisabled() => SetSpinClockwise(false); + private void OnDirectionCoilEnabledSimThread() => SetSpinClockwiseSimThread(true); + private void OnDirectionCoilDisabledSimThread() => SetSpinClockwiseSimThread(false); + + private void SetMotorOn(bool motorOn) + { + _motorOn = motorOn; + MutateState((ref TurntableState turntable) => turntable.MotorOn = motorOn); + } + + private void SetSpinClockwise(bool spinClockwise) + { + _spinClockwise = spinClockwise; + MutateState((ref TurntableState turntable) => { + turntable.SpinClockwise = spinClockwise; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + }); + } + + private void SetMotorOnSimThread(bool motorOn) + { + _motorOn = motorOn; + if (!_physicsEngine) { + return; + } + ref var turntable = ref _physicsEngine.TurntableState(_itemId); + turntable.MotorOn = motorOn; + } + + private void SetSpinClockwiseSimThread(bool spinClockwise) + { + _spinClockwise = spinClockwise; + if (!_physicsEngine) { + return; + } + ref var turntable = ref _physicsEngine.TurntableState(_itemId); + turntable.SpinClockwise = spinClockwise; + TurntablePhysics.RefreshTargetSpeed(ref turntable); + } + + private delegate void TurntableStateMutation(ref TurntableState turntable); + + private void MutateState(TurntableStateMutation mutation) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.TurntableStates.ContainsKey(_itemId)) { + return; + } + ref var turntable = ref state.TurntableStates.GetValueByRef(_itemId); + mutation(ref turntable); + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta new file mode 100644 index 000000000..2951c05e7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 204b8f5322954140abcc2c2e6290ac7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs new file mode 100644 index 000000000..f1f402e77 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -0,0 +1,148 @@ +// 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 NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [PackAs("Turntable")] + [AddComponentMenu("Pinball/Mechs/Turntable")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] + public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable + { + public const string MotorCoilItem = "motor_coil"; + public const string DirectionCoilItem = "direction_coil"; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Min(0f)] + [Unit("mm")] + [Tooltip("Planar radius in which the turntable influences balls.")] + public float Radius = 60f; + + [Tooltip("VPX-compatible maximum turntable speed.")] + public float MaxSpeed = 100f; + + [Min(0f)] + [Tooltip("Acceleration toward MaxSpeed in speed units per second.")] + public float SpinUp = 600f; + + [Min(0f)] + [Tooltip("Deceleration toward zero in speed units per second.")] + public float SpinDown = 600f; + + [Tooltip("Whether the turntable motor starts enabled before coil or script control changes it.")] + public bool MotorOnStart; + + [Tooltip("Initial spin direction.")] + public bool SpinClockwise = true; + + [Tooltip("Optional visual disc to rotate with the simulated speed.")] + public Transform RotationTarget; + + public TurntableApi TurntableApi { get; private set; } + + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(MotorCoilItem) { + Description = "Motor" + }, + new GamelogicEngineCoil(DirectionCoilItem) { + Description = "Direction" + } + }; + + IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => ((IApiCoilDevice)TurntableApi).Coil(deviceId); + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + IEnumerable IWireableComponent.AvailableWireDestinations => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems => AvailableCoils; + + public byte[] Pack() => TurntablePackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => System.Array.Empty(); + + public void Unpack(byte[] bytes) => TurntablePackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + + private void Awake() + { + var player = GetComponentInParent(); + if (player == null) { + Logger.Error($"Cannot find player for turntable {name}."); + return; + } + + var physicsEngine = GetComponentInParent(); + TurntableApi = new TurntableApi(gameObject, player, physicsEngine); + + player.Register(TurntableApi, this); + if (physicsEngine) { + physicsEngine.Register(this); + } else { + Logger.Error($"Cannot find physics engine for turntable {name}."); + } + } + + private void Update() + { + if (!RotationTarget || TurntableApi == null) { + return; + } + RotationTarget.localRotation = Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); + } + + internal TurntableState CreateState() + { + var pos = (float3)transform.localPosition.TranslateToVpx(); + var state = new TurntableState { + Position = pos.xy, + Radius = MagnetComponent.MillimetersToVpx(Radius), + Speed = 0f, + MaxSpeed = MaxSpeed, + SpinUp = SpinUp, + SpinDown = SpinDown, + MotorOn = MotorOnStart, + SpinClockwise = SpinClockwise, + RotationAngle = 0f + }; + TurntablePhysics.RefreshTargetSpeed(ref state); + return state; + } + + private void OnDrawGizmosSelected() + { + var radiusWorld = Radius * MagnetComponent.MillimetersToWorld; + if (radiusWorld <= 0f) { + return; + } + + Gizmos.color = new Color(0.95f, 0.75f, 0.1f, 0.9f); + const int segments = 64; + var previous = transform.TransformPoint(new Vector3(radiusWorld, 0f, 0f)); + for (var i = 1; i <= segments; i++) { + var angle = (math.TAU * i) / segments; + var next = transform.TransformPoint(new Vector3(math.cos(angle) * radiusWorld, 0f, math.sin(angle) * radiusWorld)); + Gizmos.DrawLine(previous, next); + previous = next; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta new file mode 100644 index 000000000..46c7e8570 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7013a596da5e46dd93e7e3482afb8112 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs new file mode 100644 index 000000000..c88382eea --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -0,0 +1,51 @@ +// 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 . + +namespace VisualPinball.Unity +{ + public class TurntablePackable + { + public float Radius; + public float MaxSpeed; + public float SpinUp; + public float SpinDown; + public bool MotorOnStart; + public bool SpinClockwise; + + public static byte[] Pack(TurntableComponent comp) + { + return PackageApi.Packer.Pack(new TurntablePackable { + Radius = comp.Radius, + MaxSpeed = comp.MaxSpeed, + SpinUp = comp.SpinUp, + SpinDown = comp.SpinDown, + MotorOnStart = comp.MotorOnStart, + SpinClockwise = comp.SpinClockwise + }); + } + + public static void Unpack(byte[] bytes, TurntableComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.Radius = data.Radius; + comp.MaxSpeed = data.MaxSpeed; + comp.SpinUp = data.SpinUp; + comp.SpinDown = data.SpinDown; + comp.MotorOnStart = data.MotorOnStart; + comp.SpinClockwise = data.SpinClockwise; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta new file mode 100644 index 000000000..7750a76a5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed3eafd6ac11441d87d71662a7576d36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs new file mode 100644 index 000000000..cbc5ceb2b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -0,0 +1,80 @@ +// 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 Unity.Burst; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + [BurstCompile(FloatPrecision.Medium, FloatMode.Fast, CompileSynchronously = true)] + internal static class TurntablePhysics + { + private const float VpxTurntableForceDivisor = 8000f; + private const float SecondsPerVpxUpdate = MagnetPhysics.VpxMagnetUpdateMs * 0.001f; + + [BurstCompile] + internal static void Update(ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) + { + UpdateSpeed(ref turntable, physicsDiffTime); + turntable.RotationAngle = math.fmod(turntable.RotationAngle + turntable.Speed * physicsDiffTime * SecondsPerVpxUpdate * 360f, 360f); + + if (turntable.Radius <= 0f || math.abs(turntable.Speed) <= 0.0001f) { + return; + } + + using (var enumerator = state.Balls.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + if (ball.IsFrozen || !IsBallInRange(in ball, in turntable)) { + continue; + } + ApplyVpxCompatibleForce(ref ball, in turntable, physicsDiffTime); + } + } + } + + internal static void UpdateSpeed(ref TurntableState turntable, float physicsDiffTime) + { + var targetSpeed = turntable.MotorOn ? turntable.TargetSpeed : 0f; + var acceleration = math.abs(targetSpeed) > math.abs(turntable.Speed) ? turntable.SpinUp : turntable.SpinDown; + turntable.Speed = MoveTowards(turntable.Speed, targetSpeed, math.max(0f, acceleration) * physicsDiffTime * SecondsPerVpxUpdate); + } + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in TurntableState turntable, float physicsDiffTime) + { + var delta = ball.Position.xy - turntable.Position; + var impulse = new float2(-delta.y, delta.x) * (turntable.Speed / VpxTurntableForceDivisor) * physicsDiffTime; + ball.Velocity = new float3(ball.Velocity.x + impulse.x, ball.Velocity.y + impulse.y, ball.Velocity.z); + } + + internal static bool IsBallInRange(in BallState ball, in TurntableState turntable) + => math.lengthsq(ball.Position.xy - turntable.Position) <= turntable.Radius * turntable.Radius; + + internal static void RefreshTargetSpeed(ref TurntableState turntable) + { + turntable.TargetSpeed = (turntable.SpinClockwise ? 1f : -1f) * turntable.MaxSpeed; + } + + private static float MoveTowards(float current, float target, float maxDelta) + { + var delta = target - current; + if (math.abs(delta) <= maxDelta) { + return target; + } + return current + math.sign(delta) * maxDelta; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta new file mode 100644 index 000000000..07337c535 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76b27281f48a41b7a80ad903ca1ba5d0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs new file mode 100644 index 000000000..1d26202d2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -0,0 +1,34 @@ +// 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 Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal struct TurntableState + { + internal float2 Position; + internal float Radius; + internal float Speed; + internal float TargetSpeed; + internal float MaxSpeed; + internal float SpinUp; + internal float SpinDown; + internal bool MotorOn; + internal bool SpinClockwise; + internal float RotationAngle; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta new file mode 100644 index 000000000..6ca26348c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 50d3de2ddfa8424eab3b6b746b9dd3d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 0148259505aab28df34b0b73b2c6ffa0fa516cc5 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:00:57 +0200 Subject: [PATCH 06/50] physics(magnets): add physical force profile Implement saturated inverse-square magnet force with velocity damping and a capped spring-damper hold path for physical grabs. Add focused coverage for force saturation, negative-strength repel, and non-teleporting hold behavior. --- .../Physics/MagnetPhysicsTests.cs | 59 ++++++++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 69 +++++++++++++++++-- 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 0736c45a7..06fed01f8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -80,6 +80,43 @@ public void VpxCompatibleForceRepelsWithNegativeStrength() Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); } + [Test] + public void PhysicalForceSaturatesInsideCoreRadius() + { + var nearBall = CreateBall(); + var coreBall = CreateBall(); + nearBall.Position = new float3(1f, 0f, 10f); + coreBall.Position = new float3(20f, 0f, 10f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 400f + }; + + MagnetPhysics.ApplyPhysicalForce(ref nearBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref coreBall, in magnet, 1f); + + Assert.That(nearBall.Velocity.x, Is.EqualTo(coreBall.Velocity.x).Within(1e-5f)); + Assert.That(nearBall.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(coreBall.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + + [Test] + public void PhysicalForceRepelsWithNegativeStrength() + { + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = -400f + }; + + MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.GreaterThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + [Test] public void VpxCompatibleGrabClampsBallToMagnetCenter() { @@ -102,6 +139,28 @@ public void VpxCompatibleGrabClampsBallToMagnetCenter() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void PhysicalHoldPullsBallWithoutTeleporting() + { + var ball = CreateBall(); + ball.Position = new float3(10f, 0f, 10f); + ball.EventPosition = new float3(10f, 0f, 10f); + ball.AngularMomentum = new float3(0f, 1f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Strength = 20f, + GrabRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f); + + Assert.That(ball.Position.x, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(ball.EventPosition.x, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(ball.Velocity.x, Is.LessThan(0f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.AngularMomentum.y, Is.LessThan(1f)); + } + [Test] public void PlanarEjectUsesKickerAngleConvention() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 98501bcbf..aab98951c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -26,6 +26,11 @@ internal static class MagnetPhysics { internal const float VpxMagnetUpdateMs = 10f; private const float MinDistance = 0.0001f; + private const float MinDistanceSq = MinDistance * MinDistance; + private const float PhysicalCoreRadiusRatio = 0.2f; + private const float PhysicalMinimumCoreRadius = 5f; + private const float PhysicalVelocityDamping = 0.02f; + private const float PhysicalMinimumHoldStrength = 1f; [BurstCompile] internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) @@ -54,15 +59,17 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState } UpdateMembership(itemId, ball.Id, true, ref state); - if (UpdateGrab(itemId, ref magnet, ref state, ref ball)) { + if (UpdateGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime)) { continue; } switch (magnet.Profile) { case MagnetForceProfile.VpxCompatible: - case MagnetForceProfile.Physical: ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime); break; + case MagnetForceProfile.Physical: + ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime); + break; } } } @@ -123,6 +130,26 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } + internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + { + var delta = ball.Position.xy - magnet.Position; + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinDistanceSq) { + return; + } + + var distance = math.sqrt(distanceSq); + var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); + var force = magnet.Strength / (effectiveDistance * effectiveDistance); + var direction = delta / distance; + var velocity = ball.Velocity.xy - direction * force * physicsDiffTime; + + var damping = math.saturate(math.abs(force) * PhysicalVelocityDamping * physicsDiffTime); + velocity *= 1f - damping; + + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + } + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet) { ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); @@ -132,6 +159,28 @@ internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState m ball.AngularMomentum = float3.zero; } + internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + { + var offset = ball.Position.xy - magnet.Position; + var velocity = ball.Velocity.xy; + var holdStrength = math.max(math.abs(magnet.Strength), PhysicalMinimumHoldStrength); + var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); + var stiffness = holdStrength / holdRadius; + var damping = 2f * math.sqrt(stiffness); + var impulse = (-offset * stiffness - velocity * damping) * physicsDiffTime; + var maxImpulse = holdStrength * physicsDiffTime; + var impulseLenSq = math.lengthsq(impulse); + var maxImpulseSq = maxImpulse * maxImpulse; + + if (impulseLenSq > maxImpulseSq && impulseLenSq > MinDistanceSq) { + impulse *= maxImpulse / math.sqrt(impulseLenSq); + } + + velocity += impulse; + ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); + ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); + } + internal static void ApplyPlanarEject(ref BallState ball, float speed, float angleDeg) { var angleRad = math.radians(angleDeg); @@ -155,7 +204,7 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } - private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball) + private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime) { var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); @@ -179,10 +228,22 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt magnet.GrabbedBalls.SetBits(bitIndex, true); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); } - ApplyVpxCompatibleGrab(ref ball, in magnet); + switch (magnet.Profile) { + case MagnetForceProfile.Physical: + ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime); + break; + default: + ApplyVpxCompatibleGrab(ref ball, in magnet); + break; + } return true; } + private static float PhysicalCoreRadius(in MagnetState magnet) + { + return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); + } + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) { if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { From 81ea637bf2360f5f48d60c2ca184f6b1adeee4d8 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:04:25 +0200 Subject: [PATCH 07/50] docs(magnets): add authoring guide Document native magnet and turntable setup, force profiles, coil and switch items, script API usage, and VPX detection guidance. Add the page to the creators-guide mechanisms navigation. docs(magnets): keep guide authoring-focused Remove the script control section from the magnet documentation and keep the usage guidance centered on editor setup, coil mapping, and force profile selection. docs(magnets): narrow compatibility guidance Remove the Terminator 2 and broad VPX script wording from the VPX Compatible profile guidance, keeping the section focused on imported magnet behavior. --- .../manual/mechanisms/magnets.md | 77 +++++++++++++++++++ .../Documentation~/creators-guide/toc.yml | 6 +- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md new file mode 100644 index 000000000..f155e60a9 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -0,0 +1,77 @@ +--- +uid: magnets +title: Magnets and Turntables +description: Native playfield magnets, ball holds, repel/eject behavior, and spinning disc turntables. +--- + +# Magnets and Turntables + +VPE provides native mechanisms for playfield magnets and spinning magnetic discs. They run inside the physics loop, so they can react every physics tick instead of waiting for a script timer. + +Use **Magnet** for radial attraction, grab/hold behavior, repulsion, and eject-style mechanisms. Use **Turntable** for a spinning disc that pushes the ball tangentially, such as VPX `cvpmTurnTable` mechanisms. + +## Magnet Setup + +Add the **Magnet** component to a GameObject with *Add Component -> Pinball -> Mechs -> Magnet*. Place the GameObject at the magnet core position on the playfield. The component uses the transform position as the center of the force field. + +The selected object shows a flat radius gizmo in the scene view. If grab is enabled, a smaller grab radius is drawn as well. + +| Field | Description | +|---|---| +| **Radius** | Planar influence radius in millimeters. Balls outside this radius are ignored. | +| **Height Range** | Vertical window above the magnet surface. Use this to avoid affecting balls on ramps above the playfield. | +| **Strength** | Magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values. Negative values repel. | +| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. | +| **Grab Ball** | Enables center hold behavior inside the grab radius. | +| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible mode clamps to center; Physical mode uses a spring-damper hold. | +| **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | +| **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | + +## Coils and Switches + +Magnets expose one coil: + +| Device item | Description | +|---|---| +| `magnet_coil` | Enables the magnet while the coil is active. | + +Magnets also expose one switch: + +| Device item | Description | +|---|---| +| `ball_held` | Closes while one or more balls are grabbed by the magnet. | + +Map ROM solenoids to `magnet_coil` in the [Coil Manager](../../editor/coil-manager.md). The `ball_held` switch can be used by game logic when a table needs explicit confirmation that a ball is held. + +## Force Profiles + +### VPX Compatible + +Use **VPX Compatible** for imported tables or ports that already have tuned `cvpmMagnet` values. This profile follows the VPX magnet force curve and normalizes it to VPE's 1 kHz physics loop. + +This mode is the default. It is the right choice for ROM-controlled magnets and Magna-Save style fields. + +### Physical + +Use **Physical** for new VPE-authored tables. It uses a saturated inverse-square force so the field gets stronger near the core without exploding at zero distance. Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. + +Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. + +## Turntable Setup + +Add the **Turntable** component with *Add Component -> Pinball -> Mechs -> Turntable*. Place it at the disc center and set **Radius** to cover the area where the spinning disc should affect the ball. + +Turntables expose two coils: + +| Device item | Description | +|---|---| +| `motor_coil` | Turns the motor on while active. | +| `direction_coil` | Selects clockwise while active and counter-clockwise while inactive. | + +The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back toward zero using **Spin Down** when the motor turns off. Assign **Rotation Target** to the visible disc mesh if you want it to rotate with the simulated speed. + +## Importing VPX Magnets + +Use *Pinball -> Tools -> Detect Magnets* to scan a VPX script after import. The tool looks for `cvpmMagnet` and `cvpmTurnTable` declarations, matches their trigger references, creates VPE components, and adds coil mappings when the script uses direct numeric solenoid callbacks. + +Review every detected item before creating components. Script expressions, custom helper classes, or nonstandard callback indirection may need manual coil mapping after detection. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 71fd85e93..2384a7fd8 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -107,8 +107,10 @@ href: manual/mechanisms/plungers.md - name: Slingshots href: manual/mechanisms/slingshots.md - - name: Light Groups - href: manual/mechanisms/light-groups.md + - name: Magnets and Turntables + href: manual/mechanisms/magnets.md + - name: Light Groups + href: manual/mechanisms/light-groups.md - name: Teleporters href: manual/mechanisms/teleporters.md - name: Drop Target Banks From e8b4e9297e9be7d6c7737409f8f1d401894d6f23 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:39:31 +0200 Subject: [PATCH 08/50] physics(magnets): match cvpmMagnet force curve The VPX-compatible profile computed ratio = dist/Size, but core.vbs uses dist/(1.5*Size), which made imported magnets about twice as weak over most of the influence radius. Also damp the impulse together with the old velocity, as core.vbs does, and add a parity test that encodes the core.vbs constants numerically instead of asserting against our own implementation. --- .../Physics/MagnetPhysicsTests.cs | 26 +++++++++++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 6 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 06fed01f8..4fcc4f8da 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -63,6 +63,32 @@ public void PlanarDampingUsesFrameFractionExponent() Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); } + [Test] + public void VpxCompatibleForceMatchesCoreVbsAttractBall() + { + // One cvpmMagnet.AttractBall update (core.vbs), ball at (50, 0), magnet at + // origin, Size = 100, Strength = 12, resting ball: + // ratio = 50 / (1.5 * 100) = 1/3 + // force = 12 * exp(-0.6) / ((1/9) * 56) * 1.5 = 1.587634 + // VelX = (0 - 50 * force / 50) * 0.985 = -1.563819 + // Ten 1ms ticks must integrate to the same velocity within ~1% (the damping + // is applied fractionally per tick, which compounds slightly differently). + var ball = CreateBall(); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 12f, + PlanarDamping = 0.985f + }; + + for (var i = 0; i < 10; i++) { + MagnetPhysics.ApplyVpxCompatibleForce(ref ball, in magnet, 0.1f); + } + + Assert.That(ball.Velocity.x, Is.EqualTo(-1.563819f).Within(0.02f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + } + [Test] public void VpxCompatibleForceRepelsWithNegativeStrength() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index aab98951c..0a4e6957c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -121,12 +121,14 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState return; } - var ratio = distance / magnet.Radius; + // cvpmMagnet.AttractBall: ratio = dist / (1.5*Size), then the damping wraps + // both the old velocity and the impulse: (vel - dir*force) * 0.985 + var ratio = distance / (1.5f * magnet.Radius); var force = magnet.Strength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; var damping = math.pow(magnet.PlanarDamping, physicsDiffTime); var direction = delta / distance; - var velocity = ball.Velocity.xy * damping - direction * force * physicsDiffTime; + var velocity = (ball.Velocity.xy - direction * force * physicsDiffTime) * damping; ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } From 3ab1e9b12ae05e113a99edb24f70a737f644ef2c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:39:57 +0200 Subject: [PATCH 09/50] physics(magnets): release grab state of destroyed balls Destroying a ball only removed it from InsideOfs, which frees its bit index for reuse while MagnetState.GrabbedBalls/ReleasedBalls kept the stale bit. The next ball assigned that index was treated as already grabbed (no BallGrabbed event) or regrab-suppressed, and since no release event ever fired, MagnetApi's ball_held switch stayed closed for the rest of the session. Clear the bits and enqueue the release event before the bit index is freed; both unregister paths run under the physics lock in external-timing mode, so the queue write is safe. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index b9cf9995f..f92727393 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -800,10 +800,34 @@ internal BallComponent UnregisterBall(int ballId) var b = _ctx.BallComponents[ballId]; _ctx.BallComponents.Remove(ballId); _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); return b; } + /// + /// Clears a destroyed ball from all magnet grab states and emits the release + /// events. Magnet grab bitfields are keyed by InsideOfs bit indices, which get + /// recycled once frees them — so this + /// must run before the ball is removed from . + /// + private void ReleaseDestroyedBallFromMagnets(int ballId) + { + if (!_ctx.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + return; + } + using (var enumerator = _ctx.MagnetStates.Ref.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var magnet = ref enumerator.Current.Value; + if (magnet.GrabbedBalls.IsSet(bitIndex)) { + magnet.GrabbedBalls.SetBits(bitIndex, false); + _ctx.EventQueue.Ref.Enqueue(new EventData(EventId.MagnetEventsBallReleased, enumerator.Current.Key, ballId, true)); + } + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + } + } + internal void RegisterRuntimeBall(BallComponent ball) { var ballId = UnityObjectId.Get(ball.gameObject); @@ -835,12 +859,14 @@ internal BallComponent UnregisterRuntimeBall(int ballId) if (_ctx.BallStates.Ref.IsCreated) { _ctx.BallStates.Ref.Remove(ballId); } + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); } return ball; } _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); return ball; From c495af940d0cea5c33e4bb5125ee5b336ff06119 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:40:23 +0200 Subject: [PATCH 10/50] magnets: route wire destinations to the coils Both components advertise their coils as wire destinations, but the APIs did not implement IApiWireDeviceDest, so Player never registered them with the wire player and wires targeting magnet_coil, motor_coil or direction_coil silently did nothing (e.g. a MagnaSave button wired straight to a magnet). Forward Wire() to the coil lookup like flippers and kickers do. --- .../VisualPinball.Unity/VPT/Magnet/MagnetApi.cs | 3 ++- .../VisualPinball.Unity/VPT/Turntable/TurntableApi.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 6aaa1f16d..e4a6751c7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -21,7 +21,7 @@ namespace VisualPinball.Unity { - public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiMagnetEvents + public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceDest, IApiMagnetEvents { private readonly MagnetComponent _component; private readonly Player _player; @@ -129,6 +129,7 @@ void IApi.OnDestroy() } IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + IApiWireDest IApiWireDeviceDest.Wire(string deviceItem) => Coil(deviceItem); IApiSwitch IApiSwitchDevice.Switch(string deviceItem) => Switch(deviceItem); private IApiCoil Coil(string deviceItem) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs index 408855ca6..1a3e01438 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs @@ -20,7 +20,7 @@ namespace VisualPinball.Unity { - public class TurntableApi : IApi, IApiCoilDevice + public class TurntableApi : IApi, IApiCoilDevice, IApiWireDeviceDest { private readonly TurntableComponent _component; private readonly Player _player; @@ -116,6 +116,7 @@ void IApi.OnDestroy() } IApiCoil IApiCoilDevice.Coil(string deviceItem) => Coil(deviceItem); + IApiWireDest IApiWireDeviceDest.Wire(string deviceItem) => Coil(deviceItem); private IApiCoil Coil(string deviceItem) { From 9384495cf309343a1ec77f946b5b7616f4864aa7 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:41:06 +0200 Subject: [PATCH 11/50] physics(turntables): bound force to disc height The tangential force only checked planar distance, so balls on ramps or wireforms crossing above the disc got curved sideways. VPX avoided this implicitly because cvpmTurntable membership came from a z-bounded trigger. Add the same height window the magnet already has, packed and editable in the inspector. --- .../VPT/Turntable/TurntableInspector.cs | 3 +++ .../VPT/Turntable/TurntableComponent.cs | 7 +++++++ .../VPT/Turntable/TurntablePackable.cs | 3 +++ .../VPT/Turntable/TurntablePhysics.cs | 9 ++++++++- .../VisualPinball.Unity/VPT/Turntable/TurntableState.cs | 2 ++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs index 0d823aaf7..c79624599 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -23,6 +23,7 @@ namespace VisualPinball.Unity.Editor public class TurntableInspector : ItemInspector { private SerializedProperty _radiusProperty; + private SerializedProperty _heightRangeProperty; private SerializedProperty _maxSpeedProperty; private SerializedProperty _spinUpProperty; private SerializedProperty _spinDownProperty; @@ -37,6 +38,7 @@ protected override void OnEnable() base.OnEnable(); _radiusProperty = serializedObject.FindProperty(nameof(TurntableComponent.Radius)); + _heightRangeProperty = serializedObject.FindProperty(nameof(TurntableComponent.HeightRange)); _maxSpeedProperty = serializedObject.FindProperty(nameof(TurntableComponent.MaxSpeed)); _spinUpProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinUp)); _spinDownProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinDown)); @@ -51,6 +53,7 @@ public override void OnInspectorGUI() OnPreInspectorGUI(); PropertyField(_radiusProperty); + PropertyField(_heightRangeProperty); PropertyField(_maxSpeedProperty); PropertyField(_spinUpProperty); PropertyField(_spinDownProperty); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index f1f402e77..fd9a6521c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -38,6 +38,11 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable [Tooltip("Planar radius in which the turntable influences balls.")] public float Radius = 60f; + [Min(0f)] + [Unit("mm")] + [Tooltip("Vertical range above the disc surface where balls are affected.")] + public float HeightRange = 50f; + [Tooltip("VPX-compatible maximum turntable speed.")] public float MaxSpeed = 100f; @@ -114,7 +119,9 @@ internal TurntableState CreateState() var pos = (float3)transform.localPosition.TranslateToVpx(); var state = new TurntableState { Position = pos.xy, + Height = pos.z, Radius = MagnetComponent.MillimetersToVpx(Radius), + HeightRange = MagnetComponent.MillimetersToVpx(HeightRange), Speed = 0f, MaxSpeed = MaxSpeed, SpinUp = SpinUp, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs index c88382eea..51a4c4d30 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -19,6 +19,7 @@ namespace VisualPinball.Unity public class TurntablePackable { public float Radius; + public float HeightRange; public float MaxSpeed; public float SpinUp; public float SpinDown; @@ -29,6 +30,7 @@ public static byte[] Pack(TurntableComponent comp) { return PackageApi.Packer.Pack(new TurntablePackable { Radius = comp.Radius, + HeightRange = comp.HeightRange, MaxSpeed = comp.MaxSpeed, SpinUp = comp.SpinUp, SpinDown = comp.SpinDown, @@ -41,6 +43,7 @@ public static void Unpack(byte[] bytes, TurntableComponent comp) { var data = PackageApi.Packer.Unpack(bytes); comp.Radius = data.Radius; + comp.HeightRange = data.HeightRange; comp.MaxSpeed = data.MaxSpeed; comp.SpinUp = data.SpinUp; comp.SpinDown = data.SpinDown; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index cbc5ceb2b..65bc22ac7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -61,7 +61,14 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in TurntableSta } internal static bool IsBallInRange(in BallState ball, in TurntableState turntable) - => math.lengthsq(ball.Position.xy - turntable.Position) <= turntable.Radius * turntable.Radius; + { + // balls on ramps or upper playfields above the disc are not affected; VPX got + // this implicitly from the z-bounded trigger that tracked the balls + if (turntable.HeightRange > 0f && (ball.Position.z < turntable.Height || ball.Position.z > turntable.Height + turntable.HeightRange)) { + return false; + } + return math.lengthsq(ball.Position.xy - turntable.Position) <= turntable.Radius * turntable.Radius; + } internal static void RefreshTargetSpeed(ref TurntableState turntable) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs index 1d26202d2..e6f424d90 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -21,7 +21,9 @@ namespace VisualPinball.Unity internal struct TurntableState { internal float2 Position; + internal float Height; internal float Radius; + internal float HeightRange; internal float Speed; internal float TargetSpeed; internal float MaxSpeed; From b81994727314f877f903769d1d952a8ea4ab81b1 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:41:51 +0200 Subject: [PATCH 12/50] turntables: scale visual disc rotation RotationAngle advanced by Speed revolutions per second, so a VPX-typical speed of 90 spun the visual disc at 5400 RPM. Speed is a force scale, not a rotation rate; map it to degrees per second through an authorable VisualSpeedFactor (default: 60 RPM at speed 90). --- .../VPT/Turntable/TurntableInspector.cs | 3 +++ .../VPT/Turntable/TurntableComponent.cs | 7 ++++++- .../VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs | 5 ++++- .../VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs | 4 +++- .../VisualPinball.Unity/VPT/Turntable/TurntableState.cs | 1 + 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs index c79624599..ecf978fb5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -30,6 +30,7 @@ public class TurntableInspector : ItemInspector private SerializedProperty _motorOnStartProperty; private SerializedProperty _spinClockwiseProperty; private SerializedProperty _rotationTargetProperty; + private SerializedProperty _visualSpeedFactorProperty; protected override MonoBehaviour UndoTarget => target as MonoBehaviour; @@ -45,6 +46,7 @@ protected override void OnEnable() _motorOnStartProperty = serializedObject.FindProperty(nameof(TurntableComponent.MotorOnStart)); _spinClockwiseProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinClockwise)); _rotationTargetProperty = serializedObject.FindProperty(nameof(TurntableComponent.RotationTarget)); + _visualSpeedFactorProperty = serializedObject.FindProperty(nameof(TurntableComponent.VisualSpeedFactor)); } public override void OnInspectorGUI() @@ -62,6 +64,7 @@ public override void OnInspectorGUI() PropertyField(_motorOnStartProperty); PropertyField(_spinClockwiseProperty); PropertyField(_rotationTargetProperty); + PropertyField(_visualSpeedFactorProperty); base.OnInspectorGUI(); EndEditing(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index fd9a6521c..c15ee5aec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -63,6 +63,10 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable [Tooltip("Optional visual disc to rotate with the simulated speed.")] public Transform RotationTarget; + [Min(0f)] + [Tooltip("Degrees per second the visual disc rotates per speed unit. At the VPX-typical speed of 90, the default of 4 spins the disc at 60 RPM.")] + public float VisualSpeedFactor = 4f; + public TurntableApi TurntableApi { get; private set; } public IEnumerable AvailableCoils => new[] { @@ -128,7 +132,8 @@ internal TurntableState CreateState() SpinDown = SpinDown, MotorOn = MotorOnStart, SpinClockwise = SpinClockwise, - RotationAngle = 0f + RotationAngle = 0f, + VisualSpeedFactor = VisualSpeedFactor }; TurntablePhysics.RefreshTargetSpeed(ref state); return state; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs index 51a4c4d30..acef4896c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -25,6 +25,7 @@ public class TurntablePackable public float SpinDown; public bool MotorOnStart; public bool SpinClockwise; + public float VisualSpeedFactor; public static byte[] Pack(TurntableComponent comp) { @@ -35,7 +36,8 @@ public static byte[] Pack(TurntableComponent comp) SpinUp = comp.SpinUp, SpinDown = comp.SpinDown, MotorOnStart = comp.MotorOnStart, - SpinClockwise = comp.SpinClockwise + SpinClockwise = comp.SpinClockwise, + VisualSpeedFactor = comp.VisualSpeedFactor }); } @@ -49,6 +51,7 @@ public static void Unpack(byte[] bytes, TurntableComponent comp) comp.SpinDown = data.SpinDown; comp.MotorOnStart = data.MotorOnStart; comp.SpinClockwise = data.SpinClockwise; + comp.VisualSpeedFactor = data.VisualSpeedFactor; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index 65bc22ac7..6b0193e3a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -29,7 +29,9 @@ internal static class TurntablePhysics internal static void Update(ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) { UpdateSpeed(ref turntable, physicsDiffTime); - turntable.RotationAngle = math.fmod(turntable.RotationAngle + turntable.Speed * physicsDiffTime * SecondsPerVpxUpdate * 360f, 360f); + // Speed is a VPX-arbitrary force scale, not a rotation rate; VisualSpeedFactor + // maps it to degrees per second for the visual disc. + turntable.RotationAngle = math.fmod(turntable.RotationAngle + turntable.Speed * turntable.VisualSpeedFactor * physicsDiffTime * SecondsPerVpxUpdate, 360f); if (turntable.Radius <= 0f || math.abs(turntable.Speed) <= 0.0001f) { return; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs index e6f424d90..a4ceeca9b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -32,5 +32,6 @@ internal struct TurntableState internal bool MotorOn; internal bool SpinClockwise; internal float RotationAngle; + internal float VisualSpeedFactor; } } From 399930efee9c726e288540464a0f3274ce01d4da Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:42:27 +0200 Subject: [PATCH 13/50] turntables: pack rotation target reference PackReferences returned an empty payload, so the RotationTarget binding was silently lost on every package save/reload and the disc stopped rotating. Round-trip it like the teleporter does with its kickers; plain Transform references are now registered in PackagedRefs so any component can pack them. --- .../Packaging/PackagedRefs.cs | 2 ++ .../VPT/Turntable/TurntableComponent.cs | 5 +++-- .../VPT/Turntable/TurntablePackable.cs | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs index 99acb1e4d..f44bb0405 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs @@ -84,6 +84,8 @@ public PackagedRefs(Transform tableRoot) foreach (var dependencyType in _referencedDependencyTypes) { (Activator.CreateInstance(dependencyType) as IReferencedDependency)!.RegisterTypes(this); } + // native Unity types that components reference directly + Add(typeof(Transform), "Transform"); // add third parties } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index c15ee5aec..509648b50 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -85,11 +85,12 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable public byte[] Pack() => TurntablePackable.Pack(this); - public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => System.Array.Empty(); + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => TurntableReferencesPackable.Pack(this, refs); public void Unpack(byte[] bytes) => TurntablePackable.Unpack(bytes, this); - public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) + => TurntableReferencesPackable.Unpack(data, this, refs); private void Awake() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs index acef4896c..8cd3d1f08 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -14,8 +14,28 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using UnityEngine; + namespace VisualPinball.Unity { + public struct TurntableReferencesPackable + { + public ReferencePackable RotationTargetRef; + + public static byte[] Pack(TurntableComponent comp, PackagedRefs refs) + { + return PackageApi.Packer.Pack(new TurntableReferencesPackable { + RotationTargetRef = refs.PackReference(comp.RotationTarget) + }); + } + + public static void Unpack(byte[] bytes, TurntableComponent comp, PackagedRefs refs) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.RotationTarget = refs.Resolve(data.RotationTargetRef); + } + } + public class TurntablePackable { public float Radius; From de45c6396244655f7f9ee5dc72db7abec514de40 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:43:05 +0200 Subject: [PATCH 14/50] turntables: register in table api TurntableApi had no name/component dictionary in TableApi, so registration was silently dropped and turntables were the only device unreachable by name. Add the dictionaries and Turntable() accessors, matching the magnet. --- .../VisualPinball.Unity/VPT/Table/TableApi.cs | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs index 10e2ea549..bb9638504 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableApi.cs @@ -31,12 +31,12 @@ public class TableApi : IApi private readonly Dictionary _dropTargetBanksByName = new Dictionary(); private readonly Dictionary _flippersByName = new Dictionary(); private readonly Dictionary _gatesByName = new Dictionary(); - private readonly Dictionary _hitTargetsByName = new Dictionary(); - private readonly Dictionary _kickersByName = new Dictionary(); - private readonly Dictionary _lightsByName = new Dictionary(); - private readonly Dictionary _lightGroupsByName = new Dictionary(); - private readonly Dictionary _magnetsByName = new Dictionary(); - private readonly Dictionary _plungersByName = new Dictionary(); + private readonly Dictionary _hitTargetsByName = new Dictionary(); + private readonly Dictionary _kickersByName = new Dictionary(); + private readonly Dictionary _lightsByName = new Dictionary(); + private readonly Dictionary _lightGroupsByName = new Dictionary(); + private readonly Dictionary _magnetsByName = new Dictionary(); + private readonly Dictionary _plungersByName = new Dictionary(); private readonly Dictionary _primitivesByName = new Dictionary(); private readonly Dictionary _rampsByName = new Dictionary(); private readonly Dictionary _rubbersByName = new Dictionary(); @@ -46,6 +46,7 @@ public class TableApi : IApi private readonly Dictionary _teleportersByName = new Dictionary(); private readonly Dictionary _triggersByName = new Dictionary(); private readonly Dictionary _troughsByName = new Dictionary(); + private readonly Dictionary _turntablesByName = new Dictionary(); private readonly Dictionary _metalWireGuidesByName = new Dictionary(); private readonly Dictionary _switchablesByName = new Dictionary(); private readonly Dictionary _hittablesByName = new Dictionary(); @@ -55,12 +56,12 @@ public class TableApi : IApi private readonly Dictionary _dropTargetBanksByComponent = new Dictionary(); private readonly Dictionary _flippersByComponent = new Dictionary(); private readonly Dictionary _gatesByComponent = new Dictionary(); - private readonly Dictionary _hitTargetsByComponent = new Dictionary(); - private readonly Dictionary _kickersByComponent = new Dictionary(); - private readonly Dictionary _lightsByComponent = new Dictionary(); - private readonly Dictionary _lightGroupsByComponent = new Dictionary(); - private readonly Dictionary _magnetsByComponent = new Dictionary(); - private readonly Dictionary _plungersByComponent = new Dictionary(); + private readonly Dictionary _hitTargetsByComponent = new Dictionary(); + private readonly Dictionary _kickersByComponent = new Dictionary(); + private readonly Dictionary _lightsByComponent = new Dictionary(); + private readonly Dictionary _lightGroupsByComponent = new Dictionary(); + private readonly Dictionary _magnetsByComponent = new Dictionary(); + private readonly Dictionary _plungersByComponent = new Dictionary(); private readonly Dictionary _primitivesByComponent = new Dictionary(); private readonly Dictionary _rampsByComponent = new Dictionary(); private readonly Dictionary _rubbersByComponent = new Dictionary(); @@ -70,6 +71,7 @@ public class TableApi : IApi private readonly Dictionary _teleportersByComponent = new Dictionary(); private readonly Dictionary _triggersByComponent = new Dictionary(); private readonly Dictionary _troughsByComponent = new Dictionary(); + private readonly Dictionary _turntablesByComponent = new Dictionary(); private readonly Dictionary _metalWireGuidesByComponent = new Dictionary(); private readonly Dictionary _switchablesByComponent = new Dictionary(); private readonly Dictionary _hittablesByComponent = new Dictionary(); @@ -128,18 +130,26 @@ public TableApi(Player player) /// /// Name of the kicker /// Kicker or `null` if no kicker with that name exists. - public KickerApi Kicker(string name) => Get(name); - public KickerApi Kicker(MonoBehaviour component) => Get(component); - - /// - /// Returns a magnet by name. - /// - /// Name of the magnet - /// Magnet or `null` if no magnet with that name exists. - public MagnetApi Magnet(string name) => Get(name); - public MagnetApi Magnet(MonoBehaviour component) => Get(component); - - /// + public KickerApi Kicker(string name) => Get(name); + public KickerApi Kicker(MonoBehaviour component) => Get(component); + + /// + /// Returns a magnet by name. + /// + /// Name of the magnet + /// Magnet or `null` if no magnet with that name exists. + public MagnetApi Magnet(string name) => Get(name); + public MagnetApi Magnet(MonoBehaviour component) => Get(component); + + /// + /// Returns a turntable by name. + /// + /// Name of the turntable + /// Turntable or `null` if no turntable with that name exists. + public TurntableApi Turntable(string name) => Get(name); + public TurntableApi Turntable(MonoBehaviour component) => Get(component); + + /// /// Returns a light by name. /// /// Name of the light @@ -300,12 +310,12 @@ private Dictionary GetNameDictionary(Type t) where T : IApi if (t == typeof(DropTargetBankApi)) return _dropTargetBanksByName as Dictionary; if (t == typeof(FlipperApi)) return _flippersByName as Dictionary; if (t == typeof(GateApi)) return _gatesByName as Dictionary; - if (t == typeof(HitTargetApi)) return _hitTargetsByName as Dictionary; - if (t == typeof(KickerApi)) return _kickersByName as Dictionary; - if (t == typeof(LightApi)) return _lightsByName as Dictionary; - if (t == typeof(LightGroupApi)) return _lightGroupsByName as Dictionary; - if (t == typeof(MagnetApi)) return _magnetsByName as Dictionary; - if (t == typeof(PlungerApi)) return _plungersByName as Dictionary; + if (t == typeof(HitTargetApi)) return _hitTargetsByName as Dictionary; + if (t == typeof(KickerApi)) return _kickersByName as Dictionary; + if (t == typeof(LightApi)) return _lightsByName as Dictionary; + if (t == typeof(LightGroupApi)) return _lightGroupsByName as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByName as Dictionary; + if (t == typeof(PlungerApi)) return _plungersByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByName as Dictionary; if (t == typeof(RampApi)) return _rampsByName as Dictionary; @@ -316,6 +326,7 @@ private Dictionary GetNameDictionary(Type t) where T : IApi if (t == typeof(TeleporterApi)) return _teleportersByName as Dictionary; if (t == typeof(TriggerApi)) return _triggersByName as Dictionary; if (t == typeof(TroughApi)) return _troughsByName as Dictionary; + if (t == typeof(TurntableApi)) return _turntablesByName as Dictionary; if (t == typeof(MetalWireGuideApi)) return _metalWireGuidesByName as Dictionary; // can be null, because we don't track all elements, namely hittable switches @@ -329,12 +340,12 @@ private Dictionary GetComponentDictionary(Type t) where T : if (t == typeof(DropTargetBankApi)) return _dropTargetBanksByComponent as Dictionary; if (t == typeof(FlipperApi)) return _flippersByComponent as Dictionary; if (t == typeof(GateApi)) return _gatesByComponent as Dictionary; - if (t == typeof(HitTargetApi)) return _hitTargetsByComponent as Dictionary; - if (t == typeof(KickerApi)) return _kickersByComponent as Dictionary; - if (t == typeof(LightApi)) return _lightsByComponent as Dictionary; - if (t == typeof(LightGroupApi)) return _lightGroupsByComponent as Dictionary; - if (t == typeof(MagnetApi)) return _magnetsByComponent as Dictionary; - if (t == typeof(PlungerApi)) return _plungersByComponent as Dictionary; + if (t == typeof(HitTargetApi)) return _hitTargetsByComponent as Dictionary; + if (t == typeof(KickerApi)) return _kickersByComponent as Dictionary; + if (t == typeof(LightApi)) return _lightsByComponent as Dictionary; + if (t == typeof(LightGroupApi)) return _lightGroupsByComponent as Dictionary; + if (t == typeof(MagnetApi)) return _magnetsByComponent as Dictionary; + if (t == typeof(PlungerApi)) return _plungersByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; if (t == typeof(PrimitiveApi)) return _primitivesByComponent as Dictionary; if (t == typeof(RampApi)) return _rampsByComponent as Dictionary; @@ -345,6 +356,7 @@ private Dictionary GetComponentDictionary(Type t) where T : if (t == typeof(TeleporterApi)) return _teleportersByComponent as Dictionary; if (t == typeof(TriggerApi)) return _triggersByComponent as Dictionary; if (t == typeof(TroughApi)) return _troughsByComponent as Dictionary; + if (t == typeof(TurntableApi)) return _turntablesByComponent as Dictionary; if (t == typeof(MetalWireGuideApi)) return _metalWireGuidesByComponent as Dictionary; // can be null, because we don't track all elements, namely hittable switches From 20f2dd1be27d444eb151e18f2f1df594969552d0 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:44:34 +0200 Subject: [PATCH 15/50] magnets: capture playfield-relative positions CreateState converted transform.localPosition with the static world-to-VPX matrix, which is only correct while every ancestor up to the playfield sits at identity. Re-parenting the magnet (e.g. under its trigger or a moved group) silently shifted the force field away from the world-space gizmo. Convert the world position relative to the playfield transform instead; turntables share the helper. --- .../VPT/Magnet/MagnetComponent.cs | 15 ++++++++++++++- .../VPT/Turntable/TurntableComponent.cs | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 7773e01c4..e2b84997b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -116,7 +116,7 @@ private void Awake() internal MagnetState CreateState() { - var pos = (float3)transform.localPosition.TranslateToVpx(); + var pos = GetPlayfieldPositionVpx(transform); return new MagnetState { Position = pos.xy, Height = pos.z, @@ -134,6 +134,19 @@ internal MagnetState CreateState() internal static float MillimetersToVpx(float value) => Physics.ScaleToVpx(value * MillimetersToWorld); + /// + /// Playfield position in VPX space, valid for any nesting depth. The local + /// position is only equivalent when every ancestor up to the playfield sits + /// at identity, which re-parenting in the editor silently breaks. + /// + internal static float3 GetPlayfieldPositionVpx(Transform transform) + { + var playfield = transform.GetComponentInParent(); + return playfield + ? (float3)transform.position.TranslateToVpx(playfield.transform) + : (float3)transform.localPosition.TranslateToVpx(); + } + private void OnDrawGizmosSelected() { var radiusWorld = Radius * MillimetersToWorld; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 509648b50..169061f77 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -121,7 +121,7 @@ private void Update() internal TurntableState CreateState() { - var pos = (float3)transform.localPosition.TranslateToVpx(); + var pos = MagnetComponent.GetPlayfieldPositionVpx(transform); var state = new TurntableState { Position = pos.xy, Height = pos.z, From dfef62ffd22415871156ee50eda27d525168ac65 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:45:29 +0200 Subject: [PATCH 16/50] editor(magnets): detect script-constant init values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InitMagnet/InitTurnTable regexes only matched numeric literals, so scripts passing a named constant (GOTG: 'kOrbMagnetPower', Ramones: 'discspeed') were silently dropped from the scan. Match identifiers too and create the device with a default plus a manual-review note. Also align the turntable ramp defaults with cvpmTurntable (SpinUp 10, SpinDown 4 units/sec) — the previous 600/600 spun discs up 60-150x faster than VPX. --- .../VPT/Magnet/MagnetDetectionWindow.cs | 23 ++++++++++++++----- .../VPT/Turntable/TurntableComponent.cs | 8 +++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs index c3417c065..ee22ad3fa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs @@ -35,8 +35,8 @@ public class MagnetDetectionWindow : EditorWindow private static readonly Regex NewDeviceRegex = new(@"^\s*Set\s+(?[A-Za-z_]\w*)\s*=\s*New\s+(?cvpmMagnet|cvpmTurntable)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex WithRegex = new(@"^\s*With\s+(?[A-Za-z_]\w*)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex EndWithRegex = new(@"^\s*End\s+With\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex InitMagnetRegex = new(@"(?[A-Za-z_]\w*)\.InitMagnet\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex InitTurntableRegex = new(@"(?[A-Za-z_]\w*)\.InitTurnTable\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex InitMagnetRegex = new(@"(?[A-Za-z_]\w*)\.InitMagnet\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?|[A-Za-z_]\w*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex InitTurntableRegex = new(@"(?[A-Za-z_]\w*)\.InitTurnTable\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?|[A-Za-z_]\w*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex GrabCenterRegex = new(@"(?[A-Za-z_]\w*)\.GrabCenter\s*=\s*(?True|False|0|1)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SolenoidRegex = new(@"(?[A-Za-z_]\w*)\.Solenoid\s*=\s*(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SolCallbackRegex = new(@"SolCallback\s*\(\s*(?[^)]+)\s*\)\s*=\s*""(?[A-Za-z_]\w*)\.(?MagnetOn|MotorOn|SpinCW)\s*=", RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -429,7 +429,7 @@ private static void ReadInit(string line, int lineNumber, Dictionary devices, private static DetectionKind ParseKind(string type) => type.Equals("cvpmTurntable", StringComparison.OrdinalIgnoreCase) ? DetectionKind.Turntable : DetectionKind.Magnet; - private static float ParseFloat(string value) - => float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) ? result : 0f; + /// + /// Parses a numeric init value; scripts often pass a named constant instead + /// (e.g. `.InitMagnet MagnaSave, kOrbMagnetPower`), which cannot be resolved + /// here — those get the fallback plus a manual-review note. + /// + private static float ParseStrength(ParsedDevice device, string value, float fallback, int lineNumber) + { + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { + return result; + } + device.Notes.Add($"Line {lineNumber}: init value is the script expression \"{value}\"; created with default {fallback.ToString(CultureInfo.InvariantCulture)}, adjust manually."); + return fallback; + } private static bool IsTruthy(string value) => value.Equals("True", StringComparison.OrdinalIgnoreCase) || value == "1"; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 169061f77..5441aef52 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -47,12 +47,12 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable public float MaxSpeed = 100f; [Min(0f)] - [Tooltip("Acceleration toward MaxSpeed in speed units per second.")] - public float SpinUp = 600f; + [Tooltip("Acceleration toward MaxSpeed in speed units per second. cvpmTurntable defaults to 10.")] + public float SpinUp = 10f; [Min(0f)] - [Tooltip("Deceleration toward zero in speed units per second.")] - public float SpinDown = 600f; + [Tooltip("Deceleration toward zero in speed units per second. cvpmTurntable defaults to 4.")] + public float SpinDown = 4f; [Tooltip("Whether the turntable motor starts enabled before coil or script control changes it.")] public bool MotorOnStart; From b2f3d97f18b767c978b4e6ca91eb0f1ae5494d25 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:47:10 +0200 Subject: [PATCH 17/50] magnets: trim hot path and api surface Hoist the per-ball math.pow out of the 1 kHz ball loop, skip the 64-bit release scan and membership lookup for idle magnets, and skip grab bookkeeping (hashmap lookups, bit index allocation) for plain attraction magnets that can never grab. Replace the BallEventArgs duplicate with the existing HitEventArgs and use Unity.Mathematics instead of Mathf. --- .../VisualPinball.Unity/VPT/EventArgs.cs | 30 +++++--------- .../VPT/Magnet/MagnetApi.cs | 19 ++++----- .../VPT/Magnet/MagnetComponent.cs | 2 +- .../VPT/Magnet/MagnetPhysics.cs | 40 +++++++++++++------ .../VPT/Turntable/TurntableApi.cs | 5 ++- 5 files changed, 51 insertions(+), 45 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs index 258430db7..077258f1e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/EventArgs.cs @@ -30,28 +30,18 @@ public struct RotationEventArgs public float AngleSpeed; } - public struct HitEventArgs - { - public int BallId; - - public HitEventArgs(int ballId) + public struct HitEventArgs + { + public int BallId; + + public HitEventArgs(int ballId) { BallId = ballId; - } - } - - public readonly struct BallEventArgs - { - public readonly int BallId; - - public BallEventArgs(int ballId) - { - BallId = ballId; - } - } - - public readonly struct SwitchEventArgs - { + } + } + + public readonly struct SwitchEventArgs + { public readonly bool IsEnabled; public readonly int BallId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index e4a6751c7..2c274e116 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using Unity.Mathematics; using UnityEngine; using VisualPinball.Unity.Collections; @@ -34,10 +35,10 @@ public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceD private readonly HashSet _heldBalls = new(); public event EventHandler Init; - public event EventHandler BallEntered; - public event EventHandler BallExited; - public event EventHandler BallGrabbed; - public event EventHandler BallReleased; + public event EventHandler BallEntered; + public event EventHandler BallExited; + public event EventHandler BallGrabbed; + public event EventHandler BallReleased; internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) { @@ -75,7 +76,7 @@ public float Strength { public float Radius { get => _component.Radius; set { - var radius = Mathf.Max(0f, value); + var radius = math.max(0f, value); _component.Radius = radius; if (!_physicsEngine) { return; @@ -177,14 +178,14 @@ private void SetEnabledSimThread(bool enabled) magnet.IsEnabled = enabled; } - void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new BallEventArgs(ballId)); - void IApiMagnetEvents.OnMagnetBallExited(int ballId) => BallExited?.Invoke(this, new BallEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new HitEventArgs(ballId)); + void IApiMagnetEvents.OnMagnetBallExited(int ballId) => BallExited?.Invoke(this, new HitEventArgs(ballId)); void IApiMagnetEvents.OnMagnetBallGrabbed(int ballId) { _heldBalls.Add(ballId); _ballHeldSwitch.SetSwitch(true); - BallGrabbed?.Invoke(this, new BallEventArgs(ballId)); + BallGrabbed?.Invoke(this, new HitEventArgs(ballId)); } void IApiMagnetEvents.OnMagnetBallReleased(int ballId) @@ -193,7 +194,7 @@ void IApiMagnetEvents.OnMagnetBallReleased(int ballId) if (_heldBalls.Count == 0) { _ballHeldSwitch.SetSwitch(false); } - BallReleased?.Invoke(this, new BallEventArgs(ballId)); + BallReleased?.Invoke(this, new HitEventArgs(ballId)); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index e2b84997b..53c4b9217 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -177,7 +177,7 @@ private void OnDrawGizmosSelected() var offset = ball.transform.position - transform.position; var planarOffset = Vector3.ProjectOnPlane(offset, transform.up); if (planarOffset.sqrMagnitude <= radiusWorld * radiusWorld) { - Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * Mathf.Min(radiusWorld * 0.25f, planarOffset.magnitude)); + Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * math.min(radiusWorld * 0.25f, planarOffset.magnitude)); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 0a4e6957c..04c8561ea 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -37,10 +37,15 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState { if (!magnet.IsEnabled) { ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); - ReleaseMembership(itemId, ref state); + if (!state.InsideOfs.IsEmpty(itemId)) { + ReleaseMembership(itemId, ref state); + } return; } + // constant within the tick; hoisted so the transcendental isn't paid per ball + var vpxDamping = math.pow(magnet.PlanarDamping, physicsDiffTime); + using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; @@ -65,7 +70,7 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState switch (magnet.Profile) { case MagnetForceProfile.VpxCompatible: - ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime); + ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); break; case MagnetForceProfile.Physical: ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime); @@ -77,16 +82,18 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) { - for (var bitIndex = 0; bitIndex < 64; bitIndex++) { - if (!magnet.GrabbedBalls.IsSet(bitIndex)) { - continue; - } - magnet.GrabbedBalls.SetBits(bitIndex, false); - if (suppressRegrab) { - magnet.ReleasedBalls.SetBits(bitIndex, true); - } - if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { - state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + if (magnet.GrabbedBalls.Value != 0UL) { + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { + if (!magnet.GrabbedBalls.IsSet(bitIndex)) { + continue; + } + magnet.GrabbedBalls.SetBits(bitIndex, false); + if (suppressRegrab) { + magnet.ReleasedBalls.SetBits(bitIndex, true); + } + if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); + } } } @@ -114,6 +121,9 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P } internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + => ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, math.pow(magnet.PlanarDamping, physicsDiffTime)); + + internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float damping) { var delta = ball.Position.xy - magnet.Position; var distance = math.length(delta); @@ -125,7 +135,6 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState // both the old velocity and the impulse: (vel - dir*force) * 0.985 var ratio = distance / (1.5f * magnet.Radius); var force = magnet.Strength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; - var damping = math.pow(magnet.PlanarDamping, physicsDiffTime); var direction = delta / distance; var velocity = (ball.Velocity.xy - direction * force * physicsDiffTime) * damping; @@ -208,6 +217,11 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime) { + // plain attraction magnets never grab; skip the bookkeeping entirely + if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { + return false; + } + var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); var isInGrabRange = magnet.GrabRadius > 0f && diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs index 1a3e01438..23f5250b0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs @@ -15,6 +15,7 @@ // along with this program. If not, see . using System; +using Unity.Mathematics; using UnityEngine; using VisualPinball.Unity.Collections; @@ -89,7 +90,7 @@ public float MaxSpeed { public float SpinUp { get => _component.SpinUp; set { - var spinUp = Mathf.Max(0f, value); + var spinUp = math.max(0f, value); _component.SpinUp = spinUp; MutateState((ref TurntableState turntable) => turntable.SpinUp = spinUp); } @@ -98,7 +99,7 @@ public float SpinUp { public float SpinDown { get => _component.SpinDown; set { - var spinDown = Mathf.Max(0f, value); + var spinDown = math.max(0f, value); _component.SpinDown = spinDown; MutateState((ref TurntableState turntable) => turntable.SpinDown = spinDown); } From 61331aa615ec5c968f049d09ce505e6ff9fb4f2f Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 19:53:09 +0200 Subject: [PATCH 18/50] physics(magnets): qualify event id in engine PhysicsEngine.cs does not import VisualPinball.Engine.Game, so the magnet release event id added for destroyed balls needs the qualified name to compile. --- VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index f92727393..4d3c935c3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -821,7 +821,7 @@ private void ReleaseDestroyedBallFromMagnets(int ballId) ref var magnet = ref enumerator.Current.Value; if (magnet.GrabbedBalls.IsSet(bitIndex)) { magnet.GrabbedBalls.SetBits(bitIndex, false); - _ctx.EventQueue.Ref.Enqueue(new EventData(EventId.MagnetEventsBallReleased, enumerator.Current.Key, ballId, true)); + _ctx.EventQueue.Ref.Enqueue(new EventData(Engine.Game.EventId.MagnetEventsBallReleased, enumerator.Current.Key, ballId, true)); } magnet.ReleasedBalls.SetBits(bitIndex, false); } From 77a39e405a3d4e8b957e3469e7f2931dddb46941 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 23:05:55 +0200 Subject: [PATCH 19/50] runtime(turntables): preserve visual rotation baseline Keep the optional rotation target's authored local rotation and apply the simulated turntable spin as a relative delta. --- .../VPT/Turntable/TurntableComponent.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 5441aef52..86eba65dc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -69,6 +69,9 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable public TurntableApi TurntableApi { get; private set; } + private Transform _rotationTarget; + private Quaternion _rotationTargetInitialRotation; + public IEnumerable AvailableCoils => new[] { new GamelogicEngineCoil(MotorCoilItem) { Description = "Motor" @@ -116,7 +119,11 @@ private void Update() if (!RotationTarget || TurntableApi == null) { return; } - RotationTarget.localRotation = Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); + if (_rotationTarget != RotationTarget) { + _rotationTarget = RotationTarget; + _rotationTargetInitialRotation = RotationTarget.localRotation; + } + RotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); } internal TurntableState CreateState() From 9abbd6c246d727c300862ed5da0f5f9f104b8961 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 23:13:52 +0200 Subject: [PATCH 20/50] physics(magnets): make states burst blittable Marshal bool fields in magnet and turntable physics state structs so Burst direct calls can pass them by reference. --- .../VisualPinball.Unity/VPT/Magnet/MagnetState.cs | 2 ++ .../VisualPinball.Unity/VPT/Turntable/TurntableState.cs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index a56b19ed5..8186d39e3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System.Runtime.InteropServices; using Unity.Collections; using Unity.Mathematics; @@ -27,6 +28,7 @@ internal struct MagnetState internal float Strength; internal float GrabRadius; internal float PlanarDamping; + [MarshalAs(UnmanagedType.U1)] internal bool IsEnabled; internal MagnetForceProfile Profile; internal float HeightRange; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs index a4ceeca9b..ba6cd2974 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System.Runtime.InteropServices; using Unity.Mathematics; namespace VisualPinball.Unity @@ -29,7 +30,9 @@ internal struct TurntableState internal float MaxSpeed; internal float SpinUp; internal float SpinDown; + [MarshalAs(UnmanagedType.U1)] internal bool MotorOn; + [MarshalAs(UnmanagedType.U1)] internal bool SpinClockwise; internal float RotationAngle; internal float VisualSpeedFactor; From 3caf54e120abb1a10c5019055f2dbb0c31a84a02 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 23:45:37 +0200 Subject: [PATCH 21/50] editor: Add and assign new icons. --- .../Assets/Editor/Icons/small_blue/magnet.png | Bin 0 -> 728 bytes .../Editor/Icons/small_blue/magnet.png.meta | 117 ++++++++++++++++++ .../Icons/small_blue/physics-thread.png | Bin 0 -> 1017 bytes .../Icons/small_blue/physics-thread.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_blue/shortcut.png | Bin 0 -> 779 bytes .../Editor/Icons/small_blue/shortcut.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_blue/tilt-bob.png | Bin 0 -> 610 bytes .../Editor/Icons/small_blue/tilt-bob.png.meta | 117 ++++++++++++++++++ .../Assets/Editor/Icons/small_gray/magnet.png | Bin 0 -> 732 bytes .../Editor/Icons/small_gray/magnet.png.meta | 117 ++++++++++++++++++ .../Icons/small_gray/physics-thread.png | Bin 0 -> 1024 bytes .../Icons/small_gray/physics-thread.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_gray/shortcut.png | Bin 0 -> 786 bytes .../Editor/Icons/small_gray/shortcut.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_gray/tilt-bob.png | Bin 0 -> 613 bytes .../Editor/Icons/small_gray/tilt-bob.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_green/magnet.png | Bin 0 -> 729 bytes .../Editor/Icons/small_green/magnet.png.meta | 117 ++++++++++++++++++ .../Icons/small_green/physics-thread.png | Bin 0 -> 1018 bytes .../Icons/small_green/physics-thread.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_green/shortcut.png | Bin 0 -> 782 bytes .../Icons/small_green/shortcut.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_green/tilt-bob.png | Bin 0 -> 609 bytes .../Icons/small_green/tilt-bob.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_orange/magnet.png | Bin 0 -> 733 bytes .../Editor/Icons/small_orange/magnet.png.meta | 117 ++++++++++++++++++ .../Icons/small_orange/physics-thread.png | Bin 0 -> 1016 bytes .../small_orange/physics-thread.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_orange/shortcut.png | Bin 0 -> 785 bytes .../Icons/small_orange/shortcut.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/small_orange/tilt-bob.png | Bin 0 -> 613 bytes .../Icons/small_orange/tilt-bob.png.meta | 117 ++++++++++++++++++ .../Game/DebugShortCutManager.cs.meta | 12 +- .../VisualPinball.Unity/Physics/Engine.meta | 8 -- .../Simulation/SimulationThread.cs.meta | 11 +- .../SimulationThreadComponent.cs.meta | 11 +- .../VPT/TiltBob/TiltBobComponent.cs.meta | 2 +- 37 files changed, 1903 insertions(+), 13 deletions(-) create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/physics-thread.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/physics-thread.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..5509f74c8cdedf41884f337f279f85db491e5155 GIT binary patch literal 728 zcmV;}0w?{6P)!61@X;j(0Bx703f{Gjoa# zxU*UvV@986P7W*ri9Gw3GMWv#5agiBIR6qem=*ekAqRE9Z75%hJz5Ca(YK{G!**zE zlbRFyT*!vrL$%ea25#xAJf{25z#P!$44cMk^*9crRCP`7<&f5)fh;MaU^9e`M@nMUWt% z65de7wFQtv;ly^ZVn`SjQz}OjkfFjkb%)W56hr<2dWmFf4DC9(XMm`ppyK5Ny{F%; zR(Bb8Q`%Xra?Y_q1sndv;#FwNOzdC)KQCYe0-ym8bpwcTerufnWt=~VZ47o+PG*79 z7sfecZMEv8PY%~MYewhG5p268B!rK7Q;>tF&d;Z-@s|9I!A~6bVvxGV>1F8IAY@EZYE^?7SBL56Ea7DcY zz5z2}0_=ejcm;Oenrci(uBa!#0=Nb;;0CBf-;1|q`(aR5)Ei)#Xi@6*Hwx$={~5u$ZQ!Z4u2^tI_dugA2$qeK_gVJeK2YF41u^&599j?y zf~7Kor6#d+SJVr_AhiWcz|(RUSJX4$P4(FcnzHCmf!KR%=81MUK;^AD$N)An2o0Ef zYxW5pKM0oX?JH%`ls~;o1Q5$S69yM0>38SIm&Cg`)q}v|&p$YZ1Xze049Q?0DbP~> zXELRoq843jv!(H~3Ggg!`o7O-7U!X_T?+I?U3Xf=74?cHGC=UfT&7!3`6dCYb-$hs z>aE$)gb>#Qc`vsWaH~U$T#tdhi#nvw^FT#n$C>U#t~KP`TeEXTJ#j^SBfp8jex(`k zrbACAw@)dMq0E#4KjpUc)=WDR!CXRY zQtC=I$rF$O_pYcPC0QJ~f#}KP{%NRY7*mu?2v%*c{M=i!mG{5oS*3t5cger$y_2+1L*Nz+dH`WPt3)0D$|#?r7WSJVrEy#nU) zoAyBJl1~rrj)bJ@QSFYSuX!W8JWy6!9%I$&3>s5h>tpJ};GVwb6PU-z_0f@bo6NQDjoxTT2(OPbOx)&hNy zF-^xhw7qE@1sXpZvv1#XYV`vFazEnxPi3aD2MbF=6|_{3*EChtOg4a@lGNtzfShQ! zqMo(3%Nb#M?>OEkm4uA7Q|;d5x+E-|oj$CP67WWDdySIZ z?(pY|5Ir(NT3xxK?mK|^-Ust1j&%9}qmGy&@Aw->I;x`u;gC#sasXaadz^%>YmQfU nMW_e%=jgh~MJ{rY|B3tt)e^+=O*Zl600000NkvXXu0mjf4t?5n literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta new file mode 100644 index 000000000..91c9ace3b --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: d1fa4fb7a6f80a047ac1f8595489696b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png new file mode 100644 index 0000000000000000000000000000000000000000..e4536d59794370700562b9bb80d153f449224d1c GIT binary patch literal 779 zcmV+m1N8ifP)BL~@W2BP{9pKT4bPGTMKO<9L0j=}wJXP0ZqN?di-~cSG;WOqW!JO>E`d8^M5ibov$5Gk#aRv*XX(*0;wn9+Qj3f> zw*G5^f*o)ikbDK!W9&`lu4QeH5;%&EAiO84ny$c{Kw%A^Feh+wuS&xd*-oNjSAY1l z#WN-z?&c75=-FZ7udQe0vUlIrD{JuybIo@p2_Q1p7+XbjD%V^e(f&%#z~&yA#Rrl7 zU3A&HuMl&lc1|`)h3IHQb9_WEyHiaUOOLZ5I_z7#fsg2$^?R?Ht`bkZD^$}Js_80B z5^?Fiz)4hM-qso_NsM=?@Wf|t0tsd&PrPeG0uu<9+-9g~e2zI&IS`0^SE}ju5^qxp zE>zR)fyyJZ46P4krUUS74Yin^4~vVf&a;mQ$tTPy4vU#$K>A(hnQd}mqEaOttRdY8 z{D9JzVZ4=MW=~*qVykwL-@2+QAJK}>t5^?h$5Yv#h463r{w4(N4LK`)@r9#&);iBN zV~IK!S@TM69m9o|hy%uSMx7}XKXsnfI?q;s#+_MO%n7a{Bnu+W;4MmniVtq`B_ATi0fe? zgh?+XJ!Ze=C1j_T8D(LQyF5F>rC2F2IUY>TKgBAm2OjuW@E2u#US;`k$x#3R002ov JPDHLkV1lZuUBUnW literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta new file mode 100644 index 000000000..3d78a81ff --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 35fc6b9ab6bfd7e4082853abcadad07c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png new file mode 100644 index 0000000000000000000000000000000000000000..2495fc2667273f0947ed673f620cf7ae521c2066 GIT binary patch literal 610 zcmV-o0-gPdP)Qa1Gso8o*2r038XYD8g8K#tz>MM>D{3_hElQ!T$`EEClGb}Btq@TD z5yfi|eHDyEr7;qf#z<5eBT;F5l2D?IErAaJRR#2JUg;*#)xO4kM?q z(M9$>&@;iM(450my4+8Rkmv(07aQQ)u_{%@=D?pwk}Zx_zt{T!gpFuG&*EGn8gSWCfd{dUy7&>65~MyaCpzY+pc}Aq*FAV{ZG!%nxo&W#<07*qoM6N<$f+SNAv;Y7A literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta new file mode 100644 index 000000000..1a7060e79 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_blue/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 84a0fc54e5c386a45bd366b815b5f90f +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..7bfb3a0185f82e528fe945d40af40589ddd082aa GIT binary patch literal 732 zcmV<20wev2P)jsJuX3O6wT3yM&k-?IS(wLZ8l|dpdY3h=wT38ATQ`}l zn5>=JQ8*h9Yz!p8!wk-#qg&pm z!v(vGft2O5@`SWn!>7R5C*|{vtsA6Zs=yck*t*%!e{rAGT_NP?v#0aM*3CN59~dED zBSODv4PH>G(i}HzRTicV&71()mW7$^s0JHVI0=v){w**5te@Q6DGRfUh<_mBRwN3p z^|6=)Bs&in(kspe0cR+xAg8H6y8dk498+eMy_d2u2LkDgg&aYpdf&MzT1OEwuE`$} zCs-0B2&kl2tGG4+awr_%36=~AlVS?3(Ey}YIOFax`69`Xe}G;hIU2Q7C-x5Dl^0Z8 zKGHk=Ru<+iBF?2&ma^h^eBqZq9JQw7U*7N)6YWmLvc2Q}L4h#Pr@}J4+ zsHCgCO`)4ff{%Pc4c9jF%wzmGhhbX0y%I8G@|dhEX?-Ppw{^fFi#9=fE}vSg@jhp-~qE%SKrYS@w@1P~a~GF)a&o z=s?T}mdXj1%HrhK`59r6+JZH(qT?>s`3dl*=IjMcS@gR=?90MT6XWiHrYy`s7O;^; zXuw5Tn0-RW3&FC3bEPbr@~01p0AiUZ!s4PN{q7R^J@H@M>a)P&&o3N90?fn>&dJ$1 zQlPbbPh?AbMFm|Pv!(U340w_;L(k{5!Fd=OmjZoO*S%J;&R4XV0fH|sWV`j2ZxXOt z_v^)=Wnp%-DP%bS`H%c;fUi2W$n_Z5`=~?aybM$%PMqsWjett`VoCDPlFO_m#2&R&nD&U50b-t4Pt5eR#IzJP?Un)K86v&ub zueW&1aVh*NmegaqvMLMn*t5VLZPi+pg?UVIoUY1TH90w}pEh?#pDvm_#s`Ge5f5bz`77N-ky_C5=2g>m_E#RYKY1qm- zKNHw5gq%4gY-b;&Zu#`$?ny|hAJx8Dmxa0M{ND$#{lu8q_cmo=z6|lCEUFWGcUy;Q z`G>Db;3OeMV)e^XEny1uR$cct3q7!vb$(-=f1=|yiBqQ5eLFBF37X6IA(c7=Kv$wU zZR-|$fgwob^cwrn?xArMXnbhQxqZ*6)ei*7{fOs(s4|TKi~;k;@v(SK+f_~E0Qf_a zI@}$QHyYOYN$0qn6SnuB<8x9?s8~BS?oF<1Lg4K5%L+LZ-pS8Fbxu~_T_aK{~Y{9pKV3n67y&ww95 z3M4Yz0uAt1uDf&he6)Wz5#{t3TBG29jbDw}0E(w322#>PvD56sokJ(r>QMoKT)Me?>G2$pK zuF{B%&bR(^fPxKh7?6Ag7GwNP<}PGyj}$nF4kxS@Evq^~Ss>AdTa*P_^QtgRk!>U@ ze)Wg5FCJ0xa1%q&pl5@Mzq+2e$=_{9zt$GFsAIkd*j0D>vk8Oj@=aJeRxPdqO3S9W)dz9@m}Pa?s9ITQYG!Q zp=}@V9ZH{v@m7kN7aW@tU)6*B%2ZX^i0(N%`C{lhmSa2$;otK8RS4=EGP;Z0g@b%H zMV>9k5;ZQ;=A~R;3==v$7tp8Emzh%8tr10@HASAyfi33D(xEIk_mE7u3{zLI5&fn8 zy%(RgqR6w25S{}|`OSsk#n<2!-6B61$PZ&+vOP}yV2P0v&W>D5=Vc(?T=87c}-+L zA4OD%m>w2fnDj!@Bl+ay8r+H literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta new file mode 100644 index 000000000..3498b6136 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 0d9335a763effb345ae11638d84eeb7c +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png new file mode 100644 index 0000000000000000000000000000000000000000..54064ac8ea2d9be0ac0f10b532c7a478e7837639 GIT binary patch literal 613 zcmV-r0-F7aP))lWLR}OqtC~X!>n}xLAZ_)~Z zTla|KBZz(o2BOj!h)QE1Dvg1tG>#;cC~XSh4j6e`8{jI>(p9VtDUaUrH%Z*urG$ZK zk1B1(!1{pLNuH&f7&{Uk?c=dotPu&KeMn4WjYNgMXV@D=g+JD#A%zkZ{^LUeN}CZd z1}4BMuDbIqJr6WK1(r&iHMVWlz$;MZSy~=?xi$2TT!Ho2xJ6_Rl=KgKgA;5kyJ^{5 z0S|eWE_)H}Xx^23UV&d72D@;DJzxQR$2!4xL=DXSA+bYLX)^=vx0aT`12#MxPj=m6 z+(Ql(7-yabYLu($f1-bWw-e&$)aX!iW7 zuib9~+`0DChL{R&eOGBS!KPf|mUa%~SN)tnc~by|(x%2H`ZG}FSy~Y{znkixI8r~b zQB1aEN^IKw#=fe*0j|B5{{?Dn$d~>`=VWrBpJ7iuru~K#gALYuPuPO3_6Y`!P*%84!avUhIy1Vc2)o00000NkvXXu0mjf^8O9( literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta new file mode 100644 index 000000000..0ba25a82d --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 44157632e169c4841acda7b12bad12a9 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..f84a91fdedf2a5cee71576f33be77c358f0b2c7c GIT binary patch literal 729 zcmV;~0w(>5P)&n`;)6<9GbaZYfkd8tNg2%sT?lee<-B={8O#d(!jOYH;5wAA#U3q$?C9H4n_)Y& zwMoqheJ*4}@1a^NTLahhRUXrAXkZTLV}?zmmAxN_QL4J8_i{+<&_EWb^QLm%Jjj9# zaG!#l8W8X{@@z0tNzhSFZk;#J3D_XyNQcy#Q;LF46wW3Bn*#}OH-j_j(JddtF$KHJ zft2)FMNHbzz?a0_hrsU}=M7RYO=1oJoHrZ#7mq2}6;d93j6A$?-mDYP14qc`4AbwS zfhed{Y0Wfjqm^w_D;GevTG`ni4Z&s=P61>`yyfNZ^`nzJTG?HO{T&&nB2hBekHsV) z)v3V{Uh!-aaE7u9avb{o)W>HL*c}BuwqCU6;mol6Of_8Id+H9ixflt0eX&PYYgo=xo3c=qM+jCJ-w&j zwX!!Ec2nAEWpmE4K?NKB#Nt(G%S`NG06))Q1OlJ|4|M~G^JeS3`Q^OXi){>cR!(Mt z(ig@lWUZC$q)!glHfu)b%Mon5BqW57c~g*sr%unOtMQi+?4k;JbV&SD$bY6uk4m_j zze00000 LNkvXXu0mjf{fa?e literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta new file mode 100644 index 000000000..640daa0d6 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 33b3a3c280b720742af8f53c484effec +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png new file mode 100644 index 0000000000000000000000000000000000000000..bd52af486446da5f89916c30678aa2678622a727 GIT binary patch literal 1018 zcmV239tuB;0@SWO@LQv)AxNwvp5fZ?NXp#U3Xf=*ZGPjGC=UfLZ(|!`6dCYb-$er zYK_~`gpkJ`$a}f1fLk3}nSP5`TNS*HjdXg~OlC-F##kJgK zl2TWyNs)jAxc7DbB+25)4Ma~S_b)>=!dA+M}jMBT;=Qh zp5uW+h%P^dK!-Y=|iJ6RMD>iJA`tvOAR4`V8@LC`a0Og0;p@!Im0 zseiD>tvjYZT5?5Gs7z(*pEwP5g9{19S@eTke4Q_)gJ*dbixTahF9Nw#kQK+- zt7{>S2bJ&?m6UxM#~SyfV53xog8Hcy(jX*)y&5ch$#}vZ&tUw5PQ%mw))0ESw~y zNKBoRR7scuy;j$q#X<+{%GdeE*ZC_gw@K_WweIVlHc8M-{tv0pApo~D(O^kay2VG3dSckSZjiW&0M`QNwdrqx>AVBU%eE+4)H1=R&NvMLB>hYSUs+!3L@Jo`~+#Qe; z4PWQ8)^<4~Oz$1X`=pYPv39E6yIhxqg|pL#6;cA;$ZfB2QlOs3*WP`d@8vq%8v(;-5a4-*KeV2N-q46nV#=IMPuaEeMBXx|0L&mfGVabX{}2 ox+_9Gus=uFMJ{rYi~LXIKk&E2T!hy*ga7~l07*qoM6N<$f*IQ5;{X5v literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta new file mode 100644 index 000000000..921285dbf --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/physics-thread.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a6ca5931e0c6084468663248d40626a5 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png new file mode 100644 index 0000000000000000000000000000000000000000..bb9572db405a3231b481d2772f6efaa69e3b3b7d GIT binary patch literal 782 zcmV+p1M&QcP)m<0 z0~PR=MtL(;$0VXfn=ODH@Y^%>6*#0(ei^H4G|{5X65s?Zwc!iuBtf0*Li=9>Y||*O z#^@VNR8H{(JoN~^q*4CfoBIqLxh4F8B0T7H2N4~kc*w>k6BTDUp`4{d!+@)_m`Wuw z8sGZw0SflOxkvI9SP!u`nY)&?9TMOyI-IbcXwhad>I7nKxJI2oZC(|IF|v(B#jgJF zWs4V7JlszqXwb7q#a~^|%4F}htKVshYt%K*N)kY1u2Hs%=-ip++K6^nasf7TWELAl z_D|8J>%M`{nVKotBov~fam}$2z3#4PGhcd~4AG(A;vH;6-?ZPyqRmS2)LBuqSzNSP ziAo|S-B%ciO3d48LkWTLE)lY0-U!%j?hF#?OktYCHs%uAxq&d~KTVy7~0!ZzQ72mTfO1!%Qh&zU(X82|tP M07*qoM6N<$f{6cZmH+?% literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta new file mode 100644 index 000000000..05da11a8f --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 414cb591cd512f4469529b9e4c95ba6a +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png new file mode 100644 index 0000000000000000000000000000000000000000..8b25ae2ba4e30fb071ae5646c203d5092c9876f4 GIT binary patch literal 609 zcmV-n0-pVeP)$sMB-q*lS1K#lsWzlMddpjq__ZGr29iB$ zteyg!BVy+&E4L|jBs@CAWAjua5=4iPP^m_eLO(Fd}}&NechjF#%)s1egLd z-~wOWDl0D|jVoYftlr?-Rs%c(mCDNM*vp-v_v8wk$EF=33!q|n*jt?ATG>s<-Wo6} zD_4Vv_B8L>KhMBJkHJ1%;SN{;UvN$c9nk=ba7gSCHCE4o$DO4WU~u8t2C|zDBge2- zS@}KCGr^^hVn3BGcT*xH`h?5H2KaiYN{!Vy@H>)Zi-Xng^*#YPaHq0z+3tl^-}v7g zxcBX+3n>-c`L3~ghD*8JFYO&Btoj9i@}>X^V|9Z|^j|=&va%*^ez(;>^Q6AvqL}Z< zRJgQz#i6Rd0j`6WKLZUe~qH2RLckVVUq00000NkvXXu0mjfIie6J literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta new file mode 100644 index 000000000..f6c39b8be --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_green/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 718ce469494cad94db11fb4962aec90e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..7c90ef3b914bb721139fc7effd7469b3952b2094 GIT binary patch literal 733 zcmV<30wVp1P)>9o_Oi z9X8lq2&62Zl_R9l8a{c(J}IAXx~f15ruK{hKvxwThA-}tx-0k`eRg!-bXBqT^am!$ z*MQJ(T7wf*sIviFji_JAjyp^y`(RPQ@CMe86!rZxE^ z-~@|;1Ob)wY8BT+K#qmuI>DkLVOC7OHR^%X3TN6KW?v*4@(<8UBuArm>V)0_oN|JS z%SQ&M-!jwQ1;p93%S;<`k5#2^_?F3Y?a1_;U;sZaU<4FL6^EuDUtLvf8D2yh;PZHs zSNbKb$#ZFr?k$j|K<4~@wplZ~U(TFumV^ZTnrDL?(|UeCUCsZQ!7eJz(Q)CQL;f>a z9hG#|f0e|ei{73@QSY3oiUvJ9ZhPRcB#VN~p8&m@bcp9M}c#O`nNXbbHybnIWu>MfTACJeoBjF+!xyT=pfBG6! zMLq|<0TW;Z?12(^0d~f^dPqlAk&l2Ga1CU@4N!}|XJg&=-Jn&GZ-9BCNe%3PrLnGz z=*WNvfgB}tZH;w{vq8m@8wGTb{|s;4Ht<+mS1h=qd!RuVc*_P^#4P(c1`2#t5L08_ zp#?F+TPnj_Y7{!JihPD2q_$uQENQt*Rpb-kRrT2knzHCGf!G`CripepKy9o$$N)An z2o1O}*6kBIe&Q_~?JH%`ls~;m01(SO;RhEa>38SIr^LH3)xE&t&p$ba1el2%^hs|Y zDbP~>Co-j-q843fv!(H~5%45!V&7*pi}Mg`mjZoO*PT|WihM;A8NmDELZ(|!`6dCY zb-$bqYOLGQgph{_vpmz?$z^&=vs4{BJYP(V7;Jc%8+a}DC4!| zBU68GtXp?XeX!(;rcfEn)IV|>>UtLv472D1$5|&RTvbIrmkyTlEEFZ$KYs}1TtU_x zXRp{o90n@kDJUsp8An#^vh{gQ_lgG}EfzFs`a$}7A1KGuw1BsarD0c9k3t;<>HlgmVjdfQso|Hv(U~f-rESJCenk<|oq)14e zqf|+l0=-t(oy9^2?5ZmAO;zMiwA?1K%hbBBBW;qPnfxD8p+f*}X`;cLrgRInKnxPu zy@oopy=fc-8a^7bZ{Krj^#dMqKj8ZhWu~DA3v+xGG*^$;G*#6^Hh>?J)aLGhoM==< zK51>2GyL@4alB6|@fmBU+P%tkiC;K7eOMtS;Emiyjgta>rt!7cs>t_popHK}s|8}+ z;mBInoj*uen_!~z$s-qeHkW6=S0A5mioP@4xj#qb$ muLt($=(@;7E^?9oiTnmoBEfd1hy-f@0000 z704wx6_5c75IYD9;6WwiutRx8?Stg<6zc~BkgMI9pR=>`Sh(YkJ3be_%ps)BlLhb- zNP$>}YoG*PqpWH|bp#P@^W*{80>3;{Ux0m-Rp+U?CKGM*Bn6JZLK{A#ED6f8588hX zu!*v&oT6_uQL*9?c-n64I{SF z;wq)cXngCx4p6WIPCb$@z-o-Y$=sE!?T`W|(cy&EqHUhUC=0~eaE-D+ZC*7DQ)C;7 zieLTZ!xzt}c(@B8Xwb7m#a~^|(&X>9t>0>kYt%N+N)kY1u28;;=-is-+K6^rat79O zWELAl_7Bme>%M~TFf}3BWGF;O30+mEex-T#im6*5DhEfK`yP>eeXKMm+W+qF#Yg_^c5KOtvP~Q0b%_BGM2}ItD zHcv*CZl02GA-b$YU76_!JZVFX2Op>Es;X>63qG8D)%P9CHlBs>A9?>S1oaOxx{I8Jlf0Hu zR;|YpH7?TTrQ9A26B;}R(5KUvnNnB$jIyeXvT6zJF=v(rWx=I~WXyG#x`B=8Z|%7d zpS2>&s+|yC0&Dp#h2YM$R%90z-50n{{fX=NSu(I19|B9)P)Av{GR=2w{hEo@xDSr5 z!FnFNJ=2_(eu%8WU8tcNhk+Lhj&L@(YZ%)uFBsX4hIXGFCf$@c`ulyig|f|C0{i(O zqC&)UvEag_8OpEc literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta new file mode 100644 index 000000000..c46466c76 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 5c38fa0fd9c22dd42a110b21be98044e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png new file mode 100644 index 0000000000000000000000000000000000000000..96468e09af9fb43c3b2dfbbc3d87637bffc6a2aa GIT binary patch literal 613 zcmV-r0-F7aP)q*lXKahZYQdG%)dGGW4>lD5!j8+Sy)k0eDH)(~y zt$RfA7DV3!15s%VM5QqhmBv6+8b=aJj8++N2aLR}4RDp@yetmA+!}gEF2j0k+#)gu3i^k=!3nmN-L&kj zfcqpbm%WH~H1EniFTk%3gI&179xww=u}<(EQ3G>-NbC?bTFrppTT4se9vhyGC%bMj zatNy=FE2bjBWwyy*-fSM?Ue9|K45dP2EOmBQlnK0JO`3&u($f1-UlEBekOUjX!iW7 zuibA7+`0DChL{R&eb;C;!KPg5mUa%~SN)uSd6NN|(W=HK`V&wkd07!RznkixI8r~b zQB1dF3T)c_#lEV)0j|B5-vc!^K95hIAk4|gb4R0+R^-9i=Yk? z;!)|=6oe1+T|W?&#^)pIF0qB6?dZRG2E^bG$N`M|;Nv~H00000NkvXXu0mjfBJC1# literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta new file mode 100644 index 000000000..c40b680ee --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/small_orange/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 8eae765855f5e7445b8bb779125e80ad +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 64 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 0 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta index 24c6d8592..5a69bb44c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DebugShortCutManager.cs.meta @@ -1,3 +1,11 @@ -fileFormatVersion: 2 +fileFormatVersion: 2 guid: 0cc90637ead34f6ab7e84e25b9179862 -timeCreated: 1745669055 \ No newline at end of file +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 5c38fa0fd9c22dd42a110b21be98044e, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta deleted file mode 100644 index 81be5fb36..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Engine.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 3207b93bf2061e84698041c3ef2346fe -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta index db138d460..8197947e8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 4dafa5d92cb47604eb814669e917b4cf \ No newline at end of file +guid: 4dafa5d92cb47604eb814669e917b4cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: bd2796541fa61b948887270d6239e265, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta index 427138b97..6d0fd2338 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 8619aa4eadc3aed469744e0a0db4bf30 \ No newline at end of file +guid: 8619aa4eadc3aed469744e0a0db4bf30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: bd2796541fa61b948887270d6239e265, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta index bdab21763..9fe4e7e3c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {instanceID: 0} + icon: {fileID: 2800000, guid: 84a0fc54e5c386a45bd366b815b5f90f, type: 3} userData: assetBundleName: assetBundleVariant: From 2722c5d324468aede5b2420976c78af7aa1fd7f4 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 5 Jul 2026 23:57:32 +0200 Subject: [PATCH 22/50] editor: Add magnet icon and tilt bob help URL. Add a dedicated magnet icon and wire it up in the toolbox and icon registry, replacing the reused coil icon. Assign the icon to the MagnetComponent script. Also add new physics-thread, shortcut and tilt-bob icons, and a HelpURL to the tilt bob component. --- .../Assets/Editor/Icons/large_gray/magnet.png | Bin 0 -> 8354 bytes .../Editor/Icons/large_gray/magnet.png.meta | 117 ++++++++++++++++++ .../Icons/large_gray/physics-thread.png | Bin 0 -> 14972 bytes .../Icons/large_gray/physics-thread.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/large_gray/shortcut.png | Bin 0 -> 9317 bytes .../Editor/Icons/large_gray/shortcut.png.meta | 117 ++++++++++++++++++ .../Editor/Icons/large_gray/tilt-bob.png | Bin 0 -> 7340 bytes .../Editor/Icons/large_gray/tilt-bob.png.meta | 117 ++++++++++++++++++ .../Toolbox/ToolboxEditor.cs | 2 +- .../VisualPinball.Unity.Editor/Utils/Icons.cs | 20 +-- .../VPT/Magnet/MagnetComponent.cs.meta | 8 +- .../VPT/TiltBob/TiltBobComponent.cs | 1 + 12 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png create mode 100644 VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png new file mode 100644 index 0000000000000000000000000000000000000000..1dc718818991ac044011451842886db4b4763282 GIT binary patch literal 8354 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 literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta new file mode 100644 index 000000000..c92bb3ed4 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/magnet.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 012370602c0f5e041ae29606ed25b58e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/physics-thread.png new file mode 100644 index 0000000000000000000000000000000000000000..71c52c4a24d5dc2afd8db00eefdedcfb439fd9f4 GIT binary patch literal 14972 zcmdVB`8$;F8$W!{7)%k$o^2v62vNdVM#_>>mdH-F79#r+Gf0xGC6zrSvQJ28hLSDI zTlQ@xk|m6No9(%LzRwTOAMpJ0bsP?knQObx>%7kEe7(-s9ecxA|Iops2O$VLWN2{7 z6oTO3UpRzh1AjJx`uD(}1KtK!z7S+3u>T9&GNqP55DGH9q+=eGu{6>2P{go`xk~yv zOkK08NMhBsLbXLEb3KKO;b)RuV3#@CDsjCBo)UFlvfi^oJ!fsUUa1hEF=LDkG(}V3 zM<-IP21wT*U(5?zH$RX1A}cHV@yh-Z{xAK(+0(w&v#eCwmi9^UXq9^Q}I@O@bU( zu;Hg_Y~A8<`e}|MZodo%%tA4hat0Db;cm==?4IRd?k-Oe0z<0kNR!Sg5;}NWIkGZe zu-M^9Si`B&(b4kq;;6@mtXRmczV%ppES{vFV0Uk{INY9arP%FP``>x2nA$O@Eu-s* zz2Tl+B^yn5QJIaJW;8PFR|^hW})-U zo7DUFW#{wqMHblBbfBb&nR}X+nk(Ehu4}JL7ld0a-9(=@yX}^n_pCXbE`J#=A&^q` zVKp`pLMf&j+QvmNk5MbB@W z{TuIpPi_eM4YO+@{J$cN8o?lor5a~F|SqNDS-U080TAf#iRHNmWLFp=S5UMN{4NQ{z0 zVx{iNAKcyhl)CXYXIuh?lQL46d}gJ0k7K|pqN7g2TkjcZfI8ymGd&S|ObYV8*rENn zG+#EEUr02RvE_bGacmp^`r1js$xI;`IK;lSwkv6`VY0}KB2h#b40BP1`gwDiovZ4m7*Y{((!R(2!-$fj931aE zw%&rz4WiHPOqXw2YE2*R90>VjTdG|&se}$#=9+qoFBv=o2CQr*P$ubjobD3>QWzR! z5;HA4>%QWVyQ8|5ne*bEukDJtG9JENn9RPA!Wi3GkNcsWeVT+fJcV5B)ow)JNHU^u zi8|)(U0yBBzh-&=mjLa#Il-LaNyv`#F!)u#V5Eqb%-o~v^x3S6srBpm=P(Mx*_Ou4 zYBNTwIt}`~it!5nu|%zc_`rKLFp3ky9@)dVkfL?vi&3L?<6)uaH+wKo#Q8KNLUhPJ z)#1|f)-DNIrbXco(1-fDovhwe=98owO@Gd&%3<`8ka~q}X*s__eBBMA?p=>VUM1=% zA=$qi?-imnNBhH(CKzWOX(i(+Ld=UeA|(!~DD4h5;25IdOBa4uTn zT?B&gk=Eqott>dWD`8$FzC!U4{tkc8<)V*aM?%JYcSuaVKB|H8Q)kzdaE+D#l}wHG z3*(1b>`2r=b3+LR(cr7`yDXFHAb{D$3PU;ggdHB zeXEJsR;?`d=Gw^?ZQoY|b*W6>-*-QT^fF&}$}w+5gny-gAQbN_QDs5v{BV5>^~iVm z2TB}HEg{LQ|HQPzpm~N5JDs}d9`;x3$vN4f zEf)N=qn^bMUz$}bL%^+nMH^~X3z4E6YGYc!bHF&_&&%ZSe>XD|rq*G`saj&ilL zy{j#DmojnBy?j%C%t=d46gQAsx}bbspl-3suqpT?Ung`D1>I>cb}AQp``71WS-&BfD8MXGmQ=%E@z{@EC z`1wEBM)bpH5&XML$LYu1sO;03uO;q5qax@}b-&}nRb{!pR9Hs@77uag8&B+hE1EDK z=A>9WOtbt_mAt{;V}~+C)vYyfV@<*mLY;DN0nCFzoR8 z=$5o(f7-m57>FU~7JnEt-RKaFibClyudrrWql7XkxtpyMe8eK%(u z{@lVl;TaT}&=rRd_PT$+{CyB~)l|;{@mwkyc4ziNjLI8&V4M9`s;lqh2ZB}SwFfQE zEU!25Ur8^(wFs^ekpcyxre&iMi#{+_Yu*=)k2=Q%<1yqbn|;KFZ-nr(>Bf_RJKLLO zE7c4-5&Obf?}U`Z3@@~jpaGr*o%emo^L5cirxqzL0UnXLAoW(Ta9b|E*`3ZwyE}23 zE-)_`J0=T159#~JK(xi)zo@SFFG|G**RL+g#0eHBp7dO2{(DERW@P5e4&0|n2+@93 zII3^XKLP{uM{p8X^|(AOxIDQ=8=n&y%Mq(m%#Z?ggZj?XX3luV{T9`exkf*C=r^y_ zb1GgLwm7SboHGz_&3+XUaW$c2G{^-z{`VaD0)NO`2^K7m@ktgRNpf-fv*9^ST&!0n7Ek#{)k#55DTGNe3}bPv*eDg>SbpT>_GQb{OSyOR9((dfK;$5> zeK%vrbaf7nPuAWHtGYci&P_YodA8{=1VvX}BwUwq9weA)PWP-CM@Q_L-DT7~SV2+_REErXPIrcX;zxc)rh#CV&ZpBAdQw)&#u)W%2iLTYU{9fNMD zkCcK`BTEb6$N2pcm=>F_w7LU&L`FKvx8SPA2k^P98+3pHbGY$7#-1!}Y z!i~noEej=Lfi-VVZTW~py^)@)onct!WL>_O1@WoV>EBeFb8y^F&{MYCb=KKuEdME_ zOa^ZkIJ>8Om9zZU$EaWVWHPl?6Fb%^4kNCblDt#Z{Eb;%Wu-o9xtJXoPw&9nJ<+vBh13JXNg)y zXH5zTglAF%r?!gsT^=GE7~ees$6Y-4jK1@Ran7M^(4ozbKbjH<`i?rCgc985m%WY- zf|-95$FT^jb|@N;PY(UBYffgrzVVvrPupp435{i2e;&oHW#p*u!KoT9Pq#!UM+^>$ z=9?_G9#f4OkvMv8dl_jh1+^i-F=iT0pz!Z#!*5ku!k5Oa2NAo5UtQV-hyh^E)zALV za4NvR29*{tvz{bEji)101Y@!kjwL3G`MoEZ2ruS_~%z{u5e(* z%8eLqToh$r273?-$u?_nH!P<}mbW_OO3~*ywkKTLsM0g}4Vk56`C?+Z%yGubLMiz6L!$)BearE0rN)*dxBT47zQn*vn>1M;I;hAl(;x zPs}rUd7z9Rd0dVepRZ}4zBMu3z@?16S931ic=>pvxt79!+89E2)qv@XSUCXha5J?} z^Q%d@O_3G0HQ)lV8|GxDfuv5)pQmCDZzgs--BHvzMnyJ8B~AzOKAJe^3nqEapEIMh z<8`xqTOnZ=TlEFUVaU#U2)0P!5eg1;`5-5ya%Ft68*cIG66F21Be%=f-cQS8rN?!m zE@gxEQt*$5dYwt;p|)Ik93K8CUTm~PbX4vjjCjC9b3h6{1y+3~Ui#BId~& z)sw1kM+{F#>0m;!WN<tan}NLm(m2PPeKs-gHO0S5o=?~kQwyB2R4Ha> zcGOFSzKZs4za$!q%F#`jXC`ua3IHEQi(5fltP2?DmBg?3+uA!ZHlej1m$+IY#wB_) z>&)s?&FWi@L30U~=6i!h6Ou0Hc1|j6p)K_O*Wgeh=O2~ZP=+iVmoS{>^RM@uKXk_R z+zw$|2DHgjRopWQ!D2BR*=LyUxIqiBr43_sk7!qh_-9t=c1Sl7lH^S zLG;@*mTcMZhhWP>?KkyHn1N5B92zPb41ISk(>ID z_USB`B>CpwsVrMICV*%rQ)GgB&kyC6$zvFvm2+-PR$wW1D;!Emn1&zu8z{r!{mzT` zI5G$qChlAKSI~G#ARfw*;3y2JLwz{*qDi~nGrX|F1vn{@)Rj3pG5qk~99SjyV&}s7 zZ^t-tK@_q# z(f!_^evL6^$VI3(8@+3;5h~Vq_##^r(<0h)9etC{os|c%lS!*RRi`*80>fNiH2TM1 zXRluUPbR7fD>bhc#UzcGB6TpBn)*A7{Tc&IO-X1jZ740!dz-~YX-BZGl^`oDDs-+N zMZCX7aD7wafPgfs6exp5yG?v7qny!A1Ze;J4=7>)e^=()!kuz=1VmroJCYdVt(cT~ zb*sHz;`%L+SkH1YV(#0X7Tr`9G36oGWqkJb2<1USd$mcVxRNdOi75QAVjNA)!|W8P(-d| z(9LLRNEk*v>vXAP6{I3tcXP1``+Fkx$YDhOv<8*5%2}WbB|Qx*sx|I%&t|Pi>l54^ zzZ{NaV`d#j9RIe6MIoU+NseB4l=ha=XG8C=wxy)tz;|b6M+kh|v)0GqxK9uM)JiMP z$`MbV-N`$)b1iZxK*=>h!=1@dM!yyZp^h0j$&1z}*$EI$i=DxyM>dg6R#0S>S`>=7 zn7x6$jezEZ!`ty4@L{s{_HwI~5RIh`g%k=6oI+;dna_->`lKL(pQg4Pt@19(thdSr zhig!#qYc)49PwN0D_T0qN*KKGuZc&3hR{%cDw|`qeT;3&oUS2TQ-JRnLI0NG79yl zy0<%fXdeRl-V0qpLZZp6JEwOJ6P(zYKXA-?y4FHn`;|Vx-z3M?fJOe7RiC^tLP)FV z8RX%CR1YSx25F`nH$0G`9m5=4JAKUIMveJeJ+171>cS#1Ge7ZgR0L56^SblhYdW_G z45vIRhoiCx(Owd6L=n0MCyT?K^f_Ls6vx?6Bnv*Xu){6@T-4~!4!?@IsOg~lT^_<_ zjXCJS63)bjbU3_D`vrhD$g8dwv}@Q61Gz&5u{*QtlPuUSvK=leQll2( zm8apLtH*|dz)l_B)Nx%8JXY>3Nq z`7D(?5|FE7_BYi6ek;xV#;Bk!X(X>SZpXF1a``Ij=H36}&4SKy`mB>`L==v$qmyTT z$a*ESq>Jq*(3Gju~(1o5rrWVe8OcJ^eT88v#2L;gJsuvn`gg)(+qr@QxE|^r zm7BPzEdaf7@hh}kqdSG^&=SuBGf|rbYDf1TfQ*Eb8KT(I+(Ax7IzjA#o zn@kpm)3DLq-^0<${;FokFB4wOsp|<%Oda1y9AVxgG>3KLCu8*j0y-P4Ew?Uz z5FYhl+qXXX%uclalE~aV{s>m~A0vB14}Q6zbac{_^(WaU`KB(_iygEfv8|*tK6cGx7yhVX`53w_;tC zrvGd^_|iK{@+7C;&gU9t-19I z%B05Sn5>ma>d`y2qcDtk10IsnJo}nea~kVpbgKRQApPMNUo}r@&kTXjn|GLpuWQ}k znppqpasOMcb}$NR!!707sRmB3h@e>Zk6| zE+zj#?@H8l)ex@oK!3i4+T?7k{G76=d^WD)Gv47EZ7rWR&e!dEI_IIa<8iEP$|q=o zN-jln(c9T!m=h&$2SaVh!RuOq)LDb52@B&;dVylOm-ddvRy9&Rv4*lS$^8kt=(NbT2{?*P`B<0t)@zh~n>-)kFvep>eqZ z9WhP&RnKx~=3-wU#TfCB*KtMS^fH-{ykW&Y+2-cY>1;^&SVnpJIsJDjt>;J3nKJ*$ zk3zKdOKd2!k_$$~+k2r3v6tvpa{WyvB^GxXdaG{rWz|q$B9W4Qo6Yca9aF9skW4?| ze29?Iw#q4NkK0cYzdO&Z4>8KaA}2NV-mK~!U_1%$QjW$7=OGonmK883A6QbgP*>hQ z`nacDH|MOPl5SiKE*V=!q3Lp?kA~z9yR3%T!eDLbDOx9Rkslt{0`J`SN@l~h6ZWi& z2`oKkd_P7!D0MXM0(G+_BdMAmgSQX;JU^0*)@PIfJbJpkvGn^GW*Lo3W+v>p|1QTg_;pgGUfS>`vNHzye&<<&ncIM$} z$HKjrm3NdAD$VMtR&GMwsVh!OP^gXq*TR(#7}3IG(5a^dZMEGKbEE%qVx9+eY3N$m zkNI4h`L8C^gy&h9ILGV*za^O~F!?V!(2cAVH*vuT+4xp2Pd#m)fq(TE0`6@a)?76U z-p-Rs$DLPIJq*J*c@X8C;=3E$md*x1U2VDZy+#_ZimX)6(bIL)V?s)ms(hrc$vGmS z4!?p12lez*mmegqoMNEol(Hd>NInW)Nc?77H%`Qq=_(1Gx{;@Z)~A-bCIehXo9q4i zpB%>x=?4KXKR4#NgxjuIG$~)d1Hk$XVFlJpB|8L)16Z0pm7ow@aio%`;`H=8V$$Wp zIW`z(#K-1fP`2S)w13C0JU-3axao>gO-ZVm4E@B5*~0)Y^U2{0aIEalH5NVwvL`r^ z>ibzHR3C{J3gR%#ZzZdPZJzOd8J`<7+~Pykw(0`o-x22w=-d#DsCdc# z;BahOSNR{+*NoPB789!*;%a2+O>J-yvhX}QO^!UHcfIqR>J_?j{38J(Y1qf|yLD{* z(4iLwiI5{IQl51jp*_O2RKR83eWZ3Mnxm!j=jF^(IqV4N+mWIV?yXm+QK^CKz1*FP zmqJbmx=cTbh8%BA1_$Q+_5i8E%JL<)AAnoaUNo4m1(k!>AfKoxlzA5lW_MyQunpOs z&guj?>$19~pQQg4tyc9|n-=tsoxyq*gBZvW@_2uEv+1iH;GQVPm)x`o!qFTwuw?b%B)z~2QlHnAj-5A-ym5BGC`dd0V| zfkiAh+u|X@sl+pJvz-I#g*l?&+`T;Gv%ox7`zBvpZI*FY{QlVQILH&z^_$wbEx&gr zPW2EBldEJ%*ZyO~wh1w>jZa#+jU_x@n8 z$m)6p@w>EY>@Dc^!~aNT@@mz1IRq44@y2J4@1c?VGhu|e@zN97l~0r`%WRp#0Ern| z@J4ArvrGr9Oy6*SWj9TF0Dny5$`0nANqY4ofwvr5!h=ZBF_%xM`4Nx^+m91aD5@1i zoe>T4QJ0zFEi^^4{fFah-J;*@@Rq|lt;9Pc#btJEKihJ_Rs3@#>eg+r75|lSGv>w_ zr9&QaZDc~_)vJ7UnHxhEpIuLV0w_kiXX9gxBAR8Nw_&*V?h}Uj(Ggp_*cVL4Suq^l zaipjYgvgN8_By^GIEccFu;=HbjE`BHkR>L?ZfLGNGCV9~_2YNk?L|+Apg_!?$*T5C zIa^*U+|R?%AI}fmj`=9E@mcwIYH1sufbiH}tpl{>s!v^V1t$a@mE6|?R5RYIcZvP> zbr_DgTBfA^ciUW)H$x4MyE+ne!DsnNA&-K+$YTL;OoC3%*R#U&%$QXmDZU5JQ8F`x z8D=>tR?!oPyo|m6+XMHU4vOfiS(@IYbBlsKlU*L_!ITjgHTco?;x}HE;-L=Nk5)k` zuG~LT>_6DsAKe_Od$oV*^x;`_o}u!*nZbZd#BlZvD4;Wi%g|BS3C5OJ{5p?*r;beIL;V&6Z0=zv=BBQ~YmVI>jkmTA{T4|6)2C!wqKEdX z4lySEFKkbYD?F-pB>yNxYkOaG)I&l_j@KUs8E99Td!GhnD4irjoO)y z=eG6|HAb=DEw1yC*>;yRLrLbb-$2RTy_OW8S-;x3@v*D-T`ATfr19zE`igPuD{}Sp zVAb_ClKs#|-W;U}+kVimF)cSlE#>HhB=ocgof!}~$JhguP2R26sVkdH5%Mu7tlrkF z-KpmhjLEPcX1BR`H1rewv9lI8#r1=7fM!-TmhaVoaaYT{%d5Cw={_|{`yKRpVNkD5 zh@b;<{eP3pj$V!!D`PJ6c8LK<>JH$CHZF4^Y)L}ns8`0Cpy!Ii#wzDg%8Y6iv|m=4 zqT@DRo%jT!R8JLrwTpt%>0F-aWWCM3t>(N{VidLDxifr_V)0xcXhnPOIaB_Y2zFzlo3{;)_+S3t zx=338y85yIekA|`)@OST5>~EUql_3Q-8YmE6!P;pInP~Q&1fV?F+2N(lR*@-3vYey z1?tnvg*E?S*idXeBTQp1_UPfO-pOAkSjIzoZh#XHn2`pH%p+wQRG)W%y%(Zo_m2b+ zq=E$yDXhpyP8=S>Gq*J@#F%|Rs`CD|g4_00Vpf>GJU=DiOU$CV!{ty=9jNIJOFq6I z*q*()_K=>iUrAb{8YiQQgspCs{l*}URA#arQiddV^+JdHxG z$zH-PhO!%q=#+1!ih7|NdeqPt1&G2x?lQ|a9&RCgk!&`SkJBOAO9=%z5w_{Op5{Z+ zj}lbpD6HbN?Rw3N6=Z6?vtYB9jV=ZvA15v{T|WtaW`&U&sH;!q?9I0>N_;F^33nnS zS~L7b28!s~H@Wvj64&<<`GZ%|ensnFf5>QPgR4qH-pz|lC!KQFiB3sm&{I~nydTVd zn(qKDFpu>~D^g>}OIkBgWyi<`_T)K4)WN-3#-F-u&3z1s5s6^OrZqBJ0f#2>$1+Lu zo|TKiFe`bw)OjqfEsC%)eaDtEaUk&k9t~-q-N|0m3$Hio_B7;+Jq#Upo3E*J7HsfB zpbf}Tk;fx(H{je1)5c#X_Q(>ypE^?Vdf8(kl%^Y#y{go7pWKZ^p>V)AF1u~=Wm(}W z&%{d}gX121bPkSJa#;Ojq{|_nsz*s8yWP(;JP0X+dQP?PV0mlbM`38?6ROy8TPxlK{68_Br9q6qf^=D@N+Uv`> z>1rE}B-YpH8ckIM6gv!^^|_}7*vjLP&||{D^@D!jDX|)O$ZKi#IRa3dPFq+>4!Atu zy3&3X;Q}XPUez78rEenzgX0h-U;G#QA8p~8C&o-SO)H~t_kqUeaT6)pT>y6`0!W1N ze_Kshe_te=jzW<$`HOyE66R!x3=E+JThurdryO(6A)%1L&!t_uCV1w1&Pbe^4)N|$ zeK&n^+Bdu9U&nfG>-9+gYC=JCB-?Nsrj~kjIzk6icXUWMF#|kHA3HNl!0~rKE#o$k zaTwa@k)=Dh1{}1K6+kp>Ew}Ri1?Q4O0srkh8}mc9)haN~?2)PQ$|W5zU~;K*e8$`D z@y_&@tm?^e6+cpta4yFl&L}KT&EwDB_p)BVLP3o|q1?X&sPgK3?~O%gHVCL8RRwj3 zFFZJ;qp%wGMzMS>TR_ruvbzP_a+4t;`2s~2Nz0;T0xJ8T^b_?Rin2$l**a(h${O0` zf0SR0xIW_AZC66V^@^2Y2$&m4Yj~ote)d+-Ggv4nlSq_rerzhCYo7r2y86G$=hF*g z(ua4+f^1lN(EA$WE?*_b>%eG^7p#N3Rs=wznPS4DxV`)|hV#M33n3b1y#s!ObC?&7 z3l9o&iqqUFjOUDm?&7xDUA*%^wUWtG9u?7DcjB`IAn8}GSn-FgTo(~$g(VLW(as8M zZTi$JfPeJS5_&3!;Rcl@w-!68nD@21s$ih2k+@x->aS<&$fu+7!lc=l4y(my-*N5Z zpo#U-8l{~nLgAkyh!4GZIB-bWmdR;_vpdX`%!ay$cS62*zga#E`FSn(FEAxf;}dCg`Eq6BMd+=3ZFoa3~E-<~-3p3zvTd zQwO_JO3EB9*r6c7K3k02APRog9#E;-ZCqdOR8ZWJXxEZW%==-*wqIDO)sQrQ#JT2C35PmIxyvoXzruBqQ*n*&L{zMRsg0aKak)qe2pJ9b!#UuLID z(^VNdnEAzUzB8zEbwXmGG?HNH^2PgJEfUylzt9j8gPE2gC3{^DpEu@Y*sZ*NRKeK` z&hc{4rS~Gn478n|4)K;w9E&VsC@??X_?z6}Vz_S(tA<`}YXiXib zki*%c!}BQ5dRw2iAL`|1I>QY$!dy zU9{(C$=SL~dUrKm!G^hlB*A(?mp>~KC*O=8|9oLE)*tq!bldB=ru#}DsA#JN+`*p$ zGq2uamWz%)ZvS(lsWI8QpVg&iQh}~9g#9;>oLfb_pZ>*e0W#JFMQTu5_h97dWTFwptEy0olU2w zWG=UoC7!hYdAtr8gX&^dt5KED9p1z?MWkxl20b?vA*Z_nknFZmU#RgmN1@Lh} z#g){Q3tgL60P&~ln&Ow};lF9=fBYdJnF9O{S{w%9v!4%pY}W16WllZu47VyiQhVV_ zuvma0&}A6fLv0KuZMfY}p=IKbr!C05wkI<&vRL&`hGH1N4J@w5~5GuNe;DLbNbb80&V(4!ese$^vS!R_dj37Wxe0s6h`8U~AxAhd) zBU9;`-PC-rd^F4&6!QCYTR)s28ddVB)OXGAkoiEu`}hj8DxVSl0(NI#?IiASjx8?o z+%iv&YK8ok^WA(6rs25w`Tb!H@)b)eC%ACSE-o`-@*hY#dH)>u3HZu3<4Np z=QUmMXU&&GOMfuNTe76V+-p^kz9gh-zi|77=hRlxtvprle`7C>3lzuEYk=e_acB2$ z1cdt9fB!Bhn7&{k`Bsr~Ja}vM*~kV!q_bKjiu}kCRJOJN--@BW+#*F60>z5{A!0HT zO1NM=Zr`qaQ6s0~3yvOwotZd%+_HJQ_6`EVU@Qc^XC@MP&wLMICKYHA7lB%=a}r2U zaSoC!ii$Ov^QL`h%GV6QeuW!&{F#Ut2WU&bslNh1cFUBqR7$6Z?EHBf8g2*71N>Ki zG7R&^V-PDlpBm_3e4|L5cEI}B04c_ZVoZJrRA$1#fzS+);!(^Ar+mw9e+5aUL<1T| z1~3W7+j(k2hTfebzXT*wvXlf!l?$k>UKdce@r(>lb8d#s{%62Zl5W5*hlgGWu!rHU z0%h3&zLe#(g_lf#rhdyfqkO?*B_5q;)OOUjU6-8U=8Jv-C1P@U6P)H5ULAyHhvJX4 zY#=a0+II7b3ggJ&780fwuU7$;{mt_A#LW6W)?(U!7Rx$bKnOpDwhYT*i^TgetMlu8 zEx(w?MujDDe=P)C1sR(Ut1~A<%P1|eK(uAQ&>2$&48Cfb{ZPwzTzuaevoh|P4tv32 z3Q^j|H=EaQoKu2YjWXT#ET^`-aX_$ck-4l;)H|=|Unx;!?&kz}NwD=*`jB@+zd~yY z+v~*9R6rvg+Dd&cs-w0FsjzF!|W~2i`y@=1cg(EwZC<)7X_cY3!d%YOa@Rt-$k#TZ#!K ziUaD^oMOL|F^08qvd@2z6!-@`dqS=M9p93TmY67u6{5lb%b&}}_!+6?8>(iR1;N!o z6D&>7-J68F#oUeOt*yN_Zhsi`nLW!xFtN*t&f?cvp_mKqx~|(*onw}co@vY|Vp#1H zln8k2u=2~01CHsR8zn0atr%>=|=s!qT_R5slnOEL;Qh@Fy&bYjO zm>o_63Q=}y=zkK4>Tk;THC~(;v`qn_do#5S?$=$sNBZzn$DzY}P=W3WgvBQe^-%E_ zKs3BG3Ks^dt#@6uAne`;)k`pp;6T*R@j7%^(aVt%!fy+DL2|lqgiY1`iAvn4Gdl!i ztIU;UOv0iOqa}BKX#89FQFI-}bY5BYw5!@b53gSUA1#TEc3J1TZ)(3cb>nAj19(MQ(LJnA%+%}t+V5Gd zTR)P=meY7;zV^#=u)K`4UruZlUr8@#zA~sDTiQO0&9^pv15VH9ec)C7QEFD7ojKYC z!pu_e%}jf<7TRZ!{?q_x19@zEhds&b%Zm-U?IuzXUy`Oven`u@3dF7ylN!$LrRgo4*oWP$)K(GNz4=VZi_Bomfud0l6hwSDlEUE)sq;jmUYrg=BsSki(+2LjkUNA($_+ znnFmR9^1#uty_3-bR=h8ag9K%lbi3i@@8R^e2jZ9l!Je3JWAK*>M^B<8KoShEy3Xi+e3@Gtw{Y)&HKi-{P~ z^{k>_SGSjtDeALH=KOB)UVhIF?LdxRlFTH2PpPl|j3wxb{2B&C?LSu4Hd%gs7W#96 zel>Q=DOE|kxt3#}Y6wT)mmD50*S<-4SZ*3f9NZy&uzw;_@PG-4#!emv!Yo7=TqEC9 zb8PrS**nd<*ZS-Q+>6<_l?Rlm$+y3%N{$vce0yZgA$~P<|t zf$v--NP!icCb!%?&j>EubH#6`Gpwg8Bv$^LL=Ek7Cz~jW>9`U|ShwsdY?l5r?O{^u zB0(2)kqnDvSv04Jo?hT1$g|3oTo(PwKm$y3%-(hRMU=bpD`y5Uf7W&=mPx~+OT1FOvPL-HGB)aw@$GG`pow!a zs;Zmi0B>kkL=wGjpLYBpb9N>0MDKt26`kJ>`?#q2r)v`UZ2L!6k3raNcMD>``9p9 z_8pgI_;T$##tD5+#sNSLeDB7Uc)NK0_1N{4KqiI7B{pdT`Pp|ls!Ani6jieK)`FF7M6_WdMx}+ zNMiUfjmISzC7Xehw93pu@I8;*fsNaQ=)^{<>a&4^4aplF^$O8-FZ`N9u$(|9k#uMd zaXQ?u`PDpl{bIQGg4I~D@$ULnwu|6}ssc%`7LHk=z!WVIXq4+Qvx{}(>?|$|T!GTv zzmS0OP)tw>>oeYbL*v{tC_Btalh5jDmHU3+ZSG;n1&1NWlrFqsSenK@eOGz4f}8D$ zJ?1kH3swLy%zdiUwA3f-DRnP4Zkk>`CgI}kvL~_Fa$e)F S1l1x4GQ4bjsqCU-N16zLpY;lJP*?j>P&Rpd(DztZgVNMwbKq94=>eO> zDTBk0;)v@=D`Kz3fw#iRM-asabzgQ%$(+y^R7MUTKB#oBgQKPM^ohsw0)zuY>gt1* zKVG}$s(jKzj;}P0w`LuHxBP$i#}`2_QwL5slfi*j!}aRjHIG#9UQN1ps`xS5j|MXv`9y}87(v3&ukSl)s6)G-MCk)nLjste>wT#EZMo>U$oS6Cw<<$5 z5g2noJed77N5J*+RBPYl%97oyW$xV+m*;kXYzed?;mvZzf>W-wX_l!zX12Q_aZp2M z{!M(ZGosQQXol!fLDH8oF~-BOYm#6}t8$Fo`a(7JMvkrxU^|Zh zA$RWHp~?4{?mQk`@ZvR`kez>%Mq-F~DhZrSd)w(^q+Rx!3+vWGfSL~om^Z5{V^S=L zZ)X|7lEL>`I>#Y;%Zt<|zw_XZ4gw@yGWmn=4ifmLpa_S7ShlY0^qCv1YW?U`)pT>; z@(=P(YqINidsxGabS?q}=AmWp_fl^er?k02)qTGax==?N)xE0=b)Vn`ZvAZaWC!mH zvX|+c@%VTL(H)C$eIio;n8LE{JQ$E?`=`AbUa*zAEwqa6@W<01@gLv08jd5Lgr!Wd z)Kp>YPe|xWoxRIXrmLT^2dm-B+NA4*d&|G0{_JW*nQ{dfaIi1}?t-v!J3SW;S^;oc z*QJW-yKu~K2y%&&lnjL(#cqq{68$9L5$SJP$4XDumFn)w{na{k<-ALBq6GMPF4lK1J)AfHPXWERfA^r>HQ zRPU|{FPTw!?~mR#TCl4ZX_gM&er%=7gcf21g~P$s-kiA9 z9GsVld=X;<4fBy5mJaz=uQ*5PIa`GYMt}CbJ}a-I?4+ zJ94_#LZTzhz)(UWdrWXpKhK4Q1!J=BQrl?2qgAISZg4t()_qc*a<6uNiFqj(NhniE zqy$Iwv!$zVD*ck(^W{v@ma+t9#tXw5FO$bp2I6&7w9Cwaj}NI)?cZ*}rA$6S=HsL; zomf=6*Dv71Q4qG2+AXN|6iI02Bs=HoGEGl&gQW-V0?|`O|EMJ(2|0_qpDClApruf?fX#;4wbu+zpa$#Ui(K)0Ix_hQpQUi4B7GLM0jG=xpjz9P<~>{k#r zBnHh=hEo;=VW+Udk<|u>Q}WPsfTk|ulrS{I2bP9d7>Nf1!pkmKCmC|0ixe5PW_77~ zjyb$r!jTUw!RZT6I7)O}l8?Rv$1bt4gc;)FTipf5;G8}h0FDYv9eBj7%+099pi3nz z!f{n%)N-ZJ2&2k}-f2hzsb9OBFWm)}lDh?|dEi;ztn3?jWw@ZkDja!40#?db|0nnOO)J6?Ij5@|-K2(vRE)5{$Z1+HmnZ zOMztV&2xw9VKMSF*~InAl4Cx@oY z)t~+C%MFr-re{{FSOdyBy7PpmQn&&8=kGJJBxbH*TNks#s|Q&K%s+3fIi9a(7b||V z-z0)oN&)A8*CycCc*`SVr5NcOCc=@X3jIBUp6o8SnhrT#JpLPZDTF_1Q?93$2mk18 zXDCIcrxrc`ubirszk*#AW7YljgEktcTA^z(LRdjVW)udBl!ijzEjLKUbmw8*v~UhG5uJkUnKjEX;-tmw;KpY zHUK3Kh9=v-BZck)LkTImOq04NRs{k;rN{O%9zCp)ckO{bQy`+!muV_DyiA?o`PG{o zvfOc~`j%X71<%I!4bhhh{q`q=Z9_nXIoSQwM`EZuSIy3sb6zB}VtmZX?a)-*`w7Zc z@8aEvSJLbpDwXPqGtAGQzYGAhWh#lGdq^?l#v1covj)WAr}vq}q#EWSP7I+yjk2Yg z5$&5bz4OJv^`C&^CW}4G-MQP>Xp!q%&@6H9>sx-jmj+YsghlWz6l+Ljs zr9kT4Qu_EF!{mhI;J9RD*)^Az8o@pfPYVsHEfyO$-Q z59cZ@%ZZoM8)-_0;g#x{()&PL;3j*)aTP${Y@vY4owGVh#8HNb`HcGC{-i6rX_R@& z?*QO6)P0TkB-CjAf)a2yu3w}lD>?=)uTHYUD#}|5@LCh@dHCtg##bp%r}p0L&EvOa zXoijccdcN^aIZU5^|pVA^|BR~`INcMQy4IDqBy@T{2>fsw8lXW>-v*v*CLpocp#&G z;UdXlH*GEue&h?xQ*5kaNH71Cxj)x6?7m_T4%Kp!RZUcfTN+Qm@4i8bbpJw!Z#_G4 zgP)Q3Ni-LJB|WByX7|%1oB)oFt77zt_S4_1q$s}?ls~w1wF7o+vhR00Gr3MuvjbZa zRx1nDVS=k-!jZ(n(^zt2pHz>+#^m^ZB-PMz4n3RHEx76=5K4Qg%>JJ`OuQ3uK!MhxL(&eI&t5362_uXqp;i=6Tmc|aMT zlkf2_rPUUKAOdIXP6b8zj#EbdvDH@;EkcLX#y8*h4}BVX6yoU=xRJp}qsez@XJKPd z?YardB-|4lxK$QW1m7E=RwH_uy%N@lPhj1F1T_+lE1oX$6R26Z>erZto8s zbc_X9GCO;9LvnWD;{ED3AopH^<)*7q76#&@sLX&fk z2ipZpG~VG((N?Zo&!J}1c<8lg>|!H?{E|ZmwDJrRP?#{N@|P35qU1-SJ2*X&s?*dZ&iFsx5m=TZhT4}rnA=`l@5 zsDye>l3b&L+gQ)JvLO`nLJ#XQMTpwB)OeFcb*cwvCIN=7LzedNwl25t+;02= z^1nhzH^`~5g9JD_w6;Ot(sN0^5r8F+GLPPUU6iwc%Bc~=IFnwu*pK^rT%lC zN^ylmlkL{M;g&Gl&bJO{SO^SU?k z%Z;lp+qBE@A@kcV@(qdH90pSp6Oix%7`^-^unY#mtfU|auFs&}&H>irQ(r68z?jhAi}r8v6& zyLD+B%>>EDXGkL7p9qUyIaY799*Ly=FOtJMkrXKSOM%HIb4>2QHm{oWk zd1tyrg=E)c;!<_ZGM+-d2rfqV66@aY$8^nj)>0jbRU6SGLulR0Q5AJpl1IO7L`G&cKA^V=^vd9Nw9Bo;8X$mr?n{f&VHZTAboSfC+J8+xUat0bJW9i;9P)X9oO;ZZyVYvs2A6(#9Da`&1*$KmzR>&l79k^Nu>Iy>LA*PtziS|D65!KoCIlQDGEW~21r(ETwIS@630F0idlJ;1+7aNF0k9}=W{`T%O&(7!x7^rQI z%#J*Sv)u7W0X1?8F@SofLLd3hc`(lq6q%vsb5NkSq!nq_z+v-)n%ycbYG-(le#$Z;;61v)U z0$XadIw=3HUJ$KuXz&CrFkOKMGN!KxxUyn7kC(ZhOVM5yBaI}w{tdn2@`)St(5>s{ z=~i`OBk8g9@46z9maz8eQU94%>8o!f--6#ujIz>K)9ljkPucD|lsIMOk9)6qUwk0b zKX)h`1G*il$%A@Kjc{!$x)n$bAqdjCOpO7Y!y4Pn7*KcKrRrFD45h6CW@UMVBMZey z%v(c0>pe7dfH^2P9=ds% zoVW=sjh7GkQ++p(KxvWFEm+CglV2=U@GjD;PdMEe`ZP@;4(SQUhH~*N=C7kZK z-Hp0MM+nv%Hy|6c$*3Gn=)nOgEZ^T(9|ONf!leF{ez(CiwWNpY0fA>fE}7)0KU?tY4TDJ>eAYChjwwWOo7QNDF@K2lm)al5b4syykTWrO`w#wv!(5mw-)?1!gbIt0s<>u!B<2{pm;%^~c>(oxX zz-UwoQv^L36^Qundm8he<&%Tu&aTUZ2w_!U1+}_0WWU-Infupkek|+ zusua`P2DmySz0mejgA{}ux`4ekd4^qqV%-8TM&7JtALA$=@tEZx}(qXX-RmKPu8_Ix7*1;aDGko$EpK>BWrb3t`1D?)x2`YQUj6k#7$K zRU7A?ojxViX!h1>Qgo-DayhOKMaFM10@Y&PaHWj|xD@T)%EhV8^CQ|IPVzOzWl2i|1 zEb0I0VRijBzIvOUrjM9hx=q^UfaJOsE`HZ0E{wa@ugBpAd%C^$6A;10@3T}NUMI3d z&jM_aIUohNEa&qEJwn##KT%&kWFzYII@}PIjom}6$*oLm1#^Ejiv~PD*SfCI;a;kG z`q9Cppxe8A^ zVQP(k04gra!^9`$*cnmCCfWYOCfT2s+*N`Zy`#bU#J6fDd_n2&v9pb=FL%cYd{Z|Z zT$O*Ql*mRvjwvV-OBtV+@5uh3o{jjX3M|Kx*BHDaJ}q*ki=d8*f}tESy+#;Rr;^m0*`kqZZ( zGi=RkIJZ|@#IOpAuhY_fYkR+%2+JQ#lb>;)G?qzKQ;NQAihw#*`qDyzG}`A_iuR+J z!}?UO*B93NXXvL`2>KGyH#CZqWGzxph{zwbKN<{4V>wEk*41*{?tubGGSdhel&2{m zJoO)uHcMgfl7HeDx*;VI$u+CJ!?b69eu%o0o!RS&6VL z=Ux7f9CNUA*`sD3>Lpgl2QXx2bRotHb*UPn!4^1C)de9?7~=Z`62Lh``3H|w2hCirmz#H{&cY=vQ- zUZ>f#P(Fjb%-go9xSFol&ihY&Qzd~?(R^{eq7zSt4DwkvtFnn>N;agmY$cx{y{cMk zg9-7UQ#I|iu(kRxWzs|CX-5bgQWLk9t<5_-Oaik{T)APu^9ySYbLB6VW}6N1r8rYK z&PgBPc#*GbERt`1eg^pv%t%}vfkib3%)?j+QSg&uyxALs+!Oembf9oT(xobkVhHD< z@)8h3F^@#%`9caUJFIcj0wid4h1@HzQ{9&%o*6?C0OkwA3&N_;L1_S*4$y?sfCV(M z81GrhF?}PHPZ9LVuS<2$VAEoTL!b`Zm>`A@r)WQex*bH=0VT7|@2$oHhm?mk-s>WU zBq3wwho%8yNDh)+O=w;~oI(gkR)t`^YbC41&!K#f&}S{wTq4+r?_g%3BbGAE#YohJ zk30`cQ;<&WDia-7JI7I~!X*8d+yJ&=g%vqS0$d|L8BmeQ15@6*Oj)lJYdg8ycO&@( z@gh*8iKWP$Q2}EYT#_X@$j)WlVC^b4kvGJMkV=tR@gb`@(Jrd=4SlutNfTlTOhVp#|9jaNyAXSpdrO4fjcDJuzw# zm=c6)N%25!mV@+(vj^m~*r0p+UuvYS{+Yr2pHM~$`1sc5{AX4v)xltIzm2h9}d~iJj!>hkPXLEmd1)$l>g=fr*!C1Oh;(dQzhu} zR7UCc-Xj_hO}9GfFqB4bLO_@{J+SVay5+2T;xgOTUV6UPVufW|3}qMr_0Ex%I+}r=Fs9rC*6)};)XRE z@1vc_BH_Rv*?7gG!+b#FlewP?2#y z*TkCK^K<)Tq}tP3iriT!X@C4zIC9LmI;jWBy&hcgsC8UHn9*Y-q321Ei%(o8RB;%= zi&)eh(`xogGeA5d961iBohLW&VMuB7dI7~KB~FHakHw(wKpkW`j{!h|oF6l+0W}|> zC@QbaQV>=p#Q0zC>k;KeH2;ymitB@O%^DsP0K$%3k8bJpb zVY2S5=(dn>Wb{n3z!G$I=iOW4g#C@n+on*?hH!y^kuv4nJ=76{Vs|=Q+n0i=C;Z`~PW!;6b$yU`dG*>=*e%vCamq02o)hNGRo;W1rvB8- wcGoD0i7%RTTiVX`y~&fC|A$9uw5T*qy=S?l$1Hmu?%&o>&qTNKoYTGk0d4U<6aWAK literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta new file mode 100644 index 000000000..cd513a071 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/shortcut.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: f2c803ec40578724fae8e1b3bd618d2e +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png new file mode 100644 index 0000000000000000000000000000000000000000..9c757223fc90baa870dbd0eb17775a1fe4cf97ed GIT binary patch literal 7340 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 literal 0 HcmV?d00001 diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta new file mode 100644 index 000000000..2b5bdf647 --- /dev/null +++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/tilt-bob.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a01fd154e0c47784ea736fe517f93545 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs index 1027d9735..1332dea4c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs @@ -182,7 +182,7 @@ private void OnGUI() CreatePrefab("Slingshots", "Prefabs/Slingshot"); } - if (CreateButton("Magnet", Icons.Coil(color: iconColor), iconSize, buttonStyle)) { + if (CreateButton("Magnet", Icons.Magnet(color: iconColor), iconSize, buttonStyle)) { CreatePrefab("Magnets", "Prefabs/Magnet"); } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs index 6e1595750..4daf9a470 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs @@ -79,11 +79,12 @@ public IconVariant(string name, IconSize size, IconColor color) private const string HitTargetName = "hit_target"; private const string KeyName = "keyboard"; private const string KickerName = "kicker"; - private const string LightGroupName = "light_group"; - private const string LightName = "light"; - private const string LoopButtonName = "player"; - private const string MechName = "mech"; - private const string MechPinMameName = "mech_pinmame"; + private const string LightGroupName = "light_group"; + private const string LightName = "light"; + private const string LoopButtonName = "player"; + private const string MagnetName = "magnet"; + private const string MechName = "mech"; + private const string MechPinMameName = "mech_pinmame"; private const string PlayfieldName = "playfield"; private const string PlayButtonName = "player"; private const string PlugName = "plug"; @@ -123,7 +124,7 @@ public IconVariant(string name, IconSize size, IconColor color) private static readonly string[] Names = { AssetLibraryName, BallRollerName, BallName, BoltName, BumperName, CalendarName, CannonName, CoilName, DropTargetBankName, DropTargetName, FlasherName, - FlipperName, GateName, GateLifterName, HitTargetName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName, + FlipperName, GateName, GateLifterName, HitTargetName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MagnetName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName, PhysicsName, PlungerName, PrimitiveName, RampName, RotatorName, RubberName, ScoreReelName, ScoreReelSingleName, SlingshotName, SpinnerName, StopButtonName, SurfaceName, SwitchNcName, SwitchNoName, TableName, TeleporterName, TriggerName, TroughName, CoilEventName, SwitchEventName, LampEventName, LampSeqName, MetalWireGuideName, @@ -191,9 +192,10 @@ private static IIconLookup[] GetLookups() { public static Texture2D Kicker(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(KickerName, size, color); public static Texture2D Light(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightName, size, color); public static Texture2D LoopButton(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LoopButtonName, size, color); - public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color); - public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color); - public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color); + public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color); + public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color); + public static Texture2D Magnet(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MagnetName, size, color); + public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color); public static Texture2D MechPinMame(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechPinMameName, size, color); public static Texture2D MetalWireGuide(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MetalWireGuideName, size, color); public static Texture2D Physics(IconSize size = IconSize.Small, IconColor color = IconColor.Gray) => Instance.GetItem(PhysicsName, size, color); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta index 9beec92fe..9d53dd367 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + icon: {fileID: 2800000, guid: 33b3a3c280b720742af8f53c484effec, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs index b9aeb9dab..9a0f8f2d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/TiltBob/TiltBobComponent.cs @@ -36,6 +36,7 @@ namespace VisualPinball.Unity [DisallowMultipleComponent] [PackAs("TiltBob")] [AddComponentMenu("Pinball/Mechs/Tilt Bob")] + [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/tilt-bobs.html")] public sealed class TiltBobComponent : MonoBehaviour, ISwitchDeviceComponent, IPackable { public const string SwitchItem = "tilt_bob_switch"; From 27a33c1bd8bedc92907dc619f1633a3871ed8d6e Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 00:12:05 +0200 Subject: [PATCH 23/50] fix(magnets): sync inspector changes at runtime Update the live magnet physics state from play-mode inspector edits and render the height range gizmo as a cylinder. --- .../VPT/Magnet/MagnetComponent.cs | 77 +++++++++++++++++-- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 53c4b9217..e6cbf4e0f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -19,6 +19,7 @@ using Unity.Mathematics; using UnityEngine; using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Collections; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -95,6 +96,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public MagnetApi MagnetApi { get; private set; } + private PhysicsEngine _physicsEngine; + private void Awake() { var player = GetComponentInParent(); @@ -103,17 +106,25 @@ private void Awake() return; } - var physicsEngine = GetComponentInParent(); - MagnetApi = new MagnetApi(gameObject, player, physicsEngine); + _physicsEngine = GetComponentInParent(); + MagnetApi = new MagnetApi(gameObject, player, _physicsEngine); player.Register(MagnetApi, this); - if (physicsEngine) { - physicsEngine.Register(this); + if (_physicsEngine) { + _physicsEngine.Register(this); } else { Logger.Error($"Cannot find physics engine for magnet {name}."); } } + private void OnValidate() + { + Radius = math.max(0f, Radius); + GrabRadius = math.max(0f, GrabRadius); + HeightRange = math.max(0f, HeightRange); + SyncPhysicsState(); + } + internal MagnetState CreateState() { var pos = GetPlayfieldPositionVpx(transform); @@ -132,6 +143,38 @@ internal MagnetState CreateState() }; } + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = UnityObjectId.Get(gameObject); + var pos = GetPlayfieldPositionVpx(transform); + var radius = MillimetersToVpx(Radius); + var strength = Strength; + var grabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f; + var profile = ForceProfile; + var heightRange = MillimetersToVpx(HeightRange); + var planarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f); + + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.MagnetStates.ContainsKey(itemId)) { + return; + } + + ref var magnet = ref state.MagnetStates.GetValueByRef(itemId); + magnet.Position = pos.xy; + magnet.Height = pos.z; + magnet.Radius = radius; + magnet.Strength = strength; + magnet.GrabRadius = grabRadius; + magnet.PlanarDamping = planarDamping; + magnet.Profile = profile; + magnet.HeightRange = heightRange; + }); + } + internal static float MillimetersToVpx(float value) => Physics.ScaleToVpx(value * MillimetersToWorld); /// @@ -163,7 +206,7 @@ private void OnDrawGizmosSelected() if (heightRangeWorld > 0f) { Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.35f); - Gizmos.DrawLine(transform.position, transform.position + transform.up * heightRangeWorld); + DrawLocalCylinder(radiusWorld, heightRangeWorld); } if (!Application.isPlaying || !DrawDebugForces) { @@ -182,20 +225,38 @@ private void OnDrawGizmosSelected() } } - private void DrawLocalDisc(float radius) + private void DrawLocalDisc(float radius, float localHeight = 0f) { if (radius <= 0f) { return; } const int segments = 64; - var previous = transform.TransformPoint(new Vector3(radius, 0f, 0f)); + var previous = transform.TransformPoint(new Vector3(radius, localHeight, 0f)); for (var i = 1; i <= segments; i++) { var angle = (math.TAU * i) / segments; - var next = transform.TransformPoint(new Vector3(math.cos(angle) * radius, 0f, math.sin(angle) * radius)); + var next = transform.TransformPoint(new Vector3(math.cos(angle) * radius, localHeight, math.sin(angle) * radius)); Gizmos.DrawLine(previous, next); previous = next; } } + + private void DrawLocalCylinder(float radius, float height) + { + if (radius <= 0f || height <= 0f) { + return; + } + + DrawLocalDisc(radius); + DrawLocalDisc(radius, height); + + const int segments = 8; + for (var i = 0; i < segments; i++) { + var angle = (math.TAU * i) / segments; + var localBase = new Vector3(math.cos(angle) * radius, 0f, math.sin(angle) * radius); + var localTop = new Vector3(localBase.x, height, localBase.z); + Gizmos.DrawLine(transform.TransformPoint(localBase), transform.TransformPoint(localTop)); + } + } } } From 24c3837b7b680c901596bed964606d8550d30e3b Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 00:33:32 +0200 Subject: [PATCH 24/50] feat(magnets): support kinematic force fields Generalize kinematic transform tracking beyond colliders and let magnets and turntables follow moving transforms during gameplay. Carry grabbed balls with magnet velocity and document the new authoring toggle. --- .../manual/mechanisms/magnets.md | 7 ++ .../VPT/Magnet/MagnetInspector.cs | 3 + .../VPT/Turntable/TurntableInspector.cs | 3 + .../Physics/MagnetPhysicsTests.cs | 55 +++++++++++++++ .../Physics/TurntablePhysicsTests.cs | 15 +++++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 16 ++--- .../Game/PhysicsEngineThreading.cs | 67 ++++++++++--------- .../Game/PhysicsKinematics.cs | 11 +-- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 2 +- .../VPT/ColliderComponent.cs | 11 ++- .../VPT/ICollidableComponent.cs | 47 ++++++------- .../VPT/Magnet/MagnetComponent.cs | 19 +++++- .../VPT/Magnet/MagnetPackable.cs | 3 + .../VPT/Magnet/MagnetPhysics.cs | 67 ++++++++++++++++--- .../VPT/Magnet/MagnetState.cs | 2 + .../VPT/Turntable/TurntableComponent.cs | 17 ++++- .../VPT/Turntable/TurntablePackable.cs | 3 + .../VPT/Turntable/TurntablePhysics.cs | 16 ++++- .../VPT/Turntable/TurntableState.cs | 2 + 19 files changed, 278 insertions(+), 88 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index f155e60a9..b5c7b724d 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -25,6 +25,7 @@ The selected object shows a flat radius gizmo in the scene view. If grab is enab | **Grab Ball** | Enables center hold behavior inside the grab radius. | | **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible mode clamps to center; Physical mode uses a spring-damper hold. | | **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | +| **Is Kinematic** | Moves the magnetic field with the GameObject transform during gameplay. Use this when the magnet is mounted on a moving mech. | | **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | ## Coils and Switches @@ -70,6 +71,12 @@ Turntables expose two coils: The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back toward zero using **Spin Down** when the motor turns off. Assign **Rotation Target** to the visible disc mesh if you want it to rotate with the simulated speed. +## Kinematic Magnets + +Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. Grabbed balls are carried with the magnet's planar velocity and keep that velocity when released. + +Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned; tilted field axes are not modeled. + ## Importing VPX Magnets Use *Pinball -> Tools -> Detect Magnets* to scan a VPX script after import. The tool looks for `cvpmMagnet` and `cvpmTurnTable` declarations, matches their trigger references, creates VPE components, and adds coil mappings when the script uses direct numeric solenoid callbacks. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index e369763a7..6e8dba011 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -29,6 +29,7 @@ public class MagnetInspector : ItemInspector private SerializedProperty _grabRadiusProperty; private SerializedProperty _heightRangeProperty; private SerializedProperty _isEnabledOnStartProperty; + private SerializedProperty _isKinematicProperty; private SerializedProperty _drawDebugForcesProperty; protected override MonoBehaviour UndoTarget => target as MonoBehaviour; @@ -44,6 +45,7 @@ protected override void OnEnable() _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); _heightRangeProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeightRange)); _isEnabledOnStartProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsEnabledOnStart)); + _isKinematicProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsKinematic)); _drawDebugForcesProperty = serializedObject.FindProperty(nameof(MagnetComponent.DrawDebugForces)); } @@ -65,6 +67,7 @@ public override void OnInspectorGUI() EditorGUILayout.Space(8f); PropertyField(_isEnabledOnStartProperty); + PropertyField(_isKinematicProperty); PropertyField(_drawDebugForcesProperty); base.OnInspectorGUI(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs index ecf978fb5..4884a0663 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -29,6 +29,7 @@ public class TurntableInspector : ItemInspector private SerializedProperty _spinDownProperty; private SerializedProperty _motorOnStartProperty; private SerializedProperty _spinClockwiseProperty; + private SerializedProperty _isKinematicProperty; private SerializedProperty _rotationTargetProperty; private SerializedProperty _visualSpeedFactorProperty; @@ -45,6 +46,7 @@ protected override void OnEnable() _spinDownProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinDown)); _motorOnStartProperty = serializedObject.FindProperty(nameof(TurntableComponent.MotorOnStart)); _spinClockwiseProperty = serializedObject.FindProperty(nameof(TurntableComponent.SpinClockwise)); + _isKinematicProperty = serializedObject.FindProperty(nameof(TurntableComponent.IsKinematic)); _rotationTargetProperty = serializedObject.FindProperty(nameof(TurntableComponent.RotationTarget)); _visualSpeedFactorProperty = serializedObject.FindProperty(nameof(TurntableComponent.VisualSpeedFactor)); } @@ -63,6 +65,7 @@ public override void OnInspectorGUI() EditorGUILayout.Space(8f); PropertyField(_motorOnStartProperty); PropertyField(_spinClockwiseProperty); + PropertyField(_isKinematicProperty); PropertyField(_rotationTargetProperty); PropertyField(_visualSpeedFactorProperty); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 4fcc4f8da..639589d65 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -165,6 +165,26 @@ public void VpxCompatibleGrabClampsBallToMagnetCenter() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void VpxCompatibleGrabCarriesKinematicMagnetVelocity() + { + var ball = CreateBall(); + ball.EventPosition = new float3(49f, -2f, 10f); + ball.Velocity = new float3(3f, -4f, 5f); + ball.OldVelocity = new float3(2f, 1f, -1f); + var magnet = new MagnetState { + Position = new float2(12f, -8f) + }; + var magnetVelocity = new float2(6f, -3f); + + MagnetPhysics.ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity); + + Assert.That(ball.Position.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.EventPosition.xy, Is.EqualTo(magnet.Position)); + Assert.That(ball.Velocity, Is.EqualTo(new float3(6f, -3f, 5f))); + Assert.That(ball.OldVelocity, Is.EqualTo(new float3(6f, -3f, -1f))); + } + [Test] public void PhysicalHoldPullsBallWithoutTeleporting() { @@ -187,6 +207,41 @@ public void PhysicalHoldPullsBallWithoutTeleporting() Assert.That(ball.AngularMomentum.y, Is.LessThan(1f)); } + [Test] + public void PhysicalHoldDampsRelativeToKinematicMagnetVelocity() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 10f); + ball.Velocity = new float3(7f, -2f, 5f); + var magnet = new MagnetState { + Position = float2.zero, + Strength = 20f, + GrabRadius = 20f + }; + var magnetVelocity = new float2(7f, -2f); + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f, magnetVelocity); + + Assert.That(ball.Velocity.x, Is.EqualTo(7f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-2f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(5f).Within(1e-5f)); + } + + [Test] + public void KinematicTransformUpdatesMagnetCenterAndHeight() + { + var magnet = new MagnetState { + Position = float2.zero, + Height = 1f + }; + var matrix = float4x4.Translate(new float3(12f, -8f, 3f)); + + MagnetPhysics.ApplyKinematicTransform(ref magnet, in matrix); + + Assert.That(magnet.Position, Is.EqualTo(new float2(12f, -8f))); + Assert.That(magnet.Height, Is.EqualTo(3f).Within(1e-5f)); + } + [Test] public void PlanarEjectUsesKickerAngleConvention() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs index 4526f3af7..05ad14b56 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs @@ -74,6 +74,21 @@ public void SpeedRampsTowardMotorTarget() Assert.That(turntable.Speed, Is.EqualTo(1f).Within(1e-5f)); } + [Test] + public void KinematicTransformUpdatesTurntableCenterAndHeight() + { + var turntable = new TurntableState { + Position = float2.zero, + Height = 1f + }; + var matrix = float4x4.Translate(new float3(12f, -8f, 3f)); + + TurntablePhysics.ApplyKinematicTransform(ref turntable, in matrix); + + Assert.That(turntable.Position, Is.EqualTo(new float2(12f, -8f))); + Assert.That(turntable.Height, Is.EqualTo(3f).Within(1e-5f)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 4d3c935c3..95606484b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -184,7 +184,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac [NonSerialized] private NudgeSystem _nudgeSystem; [NonSerialized] private VisualNudgeComponent _visualNudge; [NonSerialized] private ICollidableComponent[] _colliderComponents; - [NonSerialized] private ICollidableComponent[] _kinematicColliderComponents; + [NonSerialized] private IKinematicTransformComponent[] _kinematicTransformComponents; [NonSerialized] private float4x4 _worldToPlayfield; [NonSerialized] private int _mainThreadManagedThreadId; [NonSerialized] private int _simulationThreadManagedThreadId = -1; @@ -991,7 +991,7 @@ private void Awake() _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _colliderComponents = GetComponentsInChildren(); - _kinematicColliderComponents = _colliderComponents.Where(c => c.IsKinematic).ToArray(); + _kinematicTransformComponents = GetComponentsInChildren().Where(c => c.IsKinematic).ToArray(); } private void Start() @@ -1017,7 +1017,7 @@ private void Start() } // create static octree - Debug.Log($"Found {_colliderComponents.Length} collidable items ({_kinematicColliderComponents.Length} kinematic)."); + Debug.Log($"Found {_colliderComponents.Length} collidable items ({_kinematicTransformComponents.Length} kinematic)."); var colliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp); var kinematicColliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp, true); foreach (var colliderItem in _colliderComponents) { @@ -1042,10 +1042,10 @@ private void Start() // get kinematic collider matrices _worldToPlayfield = playfield.transform.worldToLocalMatrix; - foreach (var coll in _kinematicColliderComponents) { - var matrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); - _ctx.KinematicTransforms.Ref[coll.ItemId] = matrix; - _ctx.MainThreadKinematicCache[coll.ItemId] = matrix; + foreach (var item in _kinematicTransformComponents) { + var matrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + _ctx.KinematicTransforms.Ref[item.ItemId] = matrix; + _ctx.MainThreadKinematicCache[item.ItemId] = matrix; } #if UNITY_EDITOR _ctx.ColliderLookups = colliders.CreateLookup(Allocator.Persistent); @@ -1085,7 +1085,7 @@ private void Start() } // Create threading helper (all context fields are now populated) - _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicColliderComponents, _worldToPlayfield); + _threading = new PhysicsEngineThreading(this, _ctx, _player, _kinematicTransformComponents, _worldToPlayfield); // Mark as initialized for simulation thread _ctx.IsInitialized = true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 2d148ecb1..106d12b59 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -49,8 +49,8 @@ internal class PhysicsEngineThreading private readonly PhysicsEngine _physicsEngine; private readonly PhysicsEngineContext _ctx; private readonly Player _player; - private readonly ICollidableComponent[] _kinematicColliderComponents; - private readonly Dictionary _kinematicColliderComponentsByItemId; + private readonly IKinematicTransformComponent[] _kinematicTransformComponents; + private readonly Dictionary _kinematicTransformComponentsByItemId; private readonly float4x4 _worldToPlayfield; private readonly PhysicsMovements _physicsMovements = new(); private readonly List _deferredMainThreadEvents = new(); @@ -101,16 +101,16 @@ private struct HeldKinematicPose private bool _float2SnapshotOverflowWarningIssued; internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContext ctx, Player player, - ICollidableComponent[] kinematicColliderComponents, float4x4 worldToPlayfield) + IKinematicTransformComponent[] kinematicTransformComponents, float4x4 worldToPlayfield) { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _ctx = ctx ?? throw new ArgumentNullException(nameof(ctx)); _player = player; - _kinematicColliderComponents = kinematicColliderComponents; - _kinematicColliderComponentsByItemId = new Dictionary(kinematicColliderComponents?.Length ?? 0); - if (kinematicColliderComponents != null) { - foreach (var coll in kinematicColliderComponents) { - _kinematicColliderComponentsByItemId[coll.ItemId] = coll; + _kinematicTransformComponents = kinematicTransformComponents; + _kinematicTransformComponentsByItemId = new Dictionary(kinematicTransformComponents?.Length ?? 0); + if (kinematicTransformComponents != null) { + foreach (var item in kinematicTransformComponents) { + _kinematicTransformComponentsByItemId[item.ItemId] = item; } } _snapshotFlipperIds = SnapshotIds(_ctx.FlipperStates.Ref); @@ -358,8 +358,8 @@ private void StageKinematicTarget(int itemId, in float4x4 matrix, ulong nowUsec) _ctx.KinematicOctreeDirty = true; } - var coll = GetKinematicColliderComponent(itemId); - coll?.OnTransformationChanged(matrix); + var item = GetKinematicTransformComponent(itemId); + item?.OnTransformationChanged(matrix); } /// @@ -372,9 +372,10 @@ private void SnapKinematicPose(int itemId, in float4x4 matrix) _ctx.KinematicTargetTransforms.Ref[itemId] = matrix; // keep in sync, stepping skips equal poses var state = _ctx.CreateState(); - ref var colliderLookups = ref _ctx.KinematicColliderLookups.GetValueByRef(itemId); - for (var i = 0; i < colliderLookups.Length; i++) { - state.TransformKinematicColliders(colliderLookups[i], matrix); + if (_ctx.KinematicColliderLookups.TryGetValue(itemId, out var colliderLookups)) { + for (var i = 0; i < colliderLookups.Length; i++) { + state.TransformKinematicColliders(colliderLookups[i], matrix); + } } _ctx.KinematicOctreeDirty = true; } @@ -462,9 +463,9 @@ private void StopKinematicVelocity(int itemId, ulong nowUsec) } } - private ICollidableComponent GetKinematicColliderComponent(int itemId) + private IKinematicTransformComponent GetKinematicTransformComponent(int itemId) { - return _kinematicColliderComponentsByItemId.TryGetValue(itemId, out var coll) ? coll : null; + return _kinematicTransformComponentsByItemId.TryGetValue(itemId, out var item) ? item : null; } /// @@ -752,29 +753,29 @@ internal void DrainExternalThreadCallbacks() /// internal void UpdateKinematicTransformsFromMainThread() { - if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicColliderComponents == null) return; + if (!_ctx.UseExternalTiming || !_ctx.IsInitialized || _kinematicTransformComponents == null) return; var scanStartTicks = Stopwatch.GetTimestamp(); _pendingKinematicUpdates.Clear(); _pendingKinematicStopUpdates.Clear(); - foreach (var coll in _kinematicColliderComponents) { - var currMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + foreach (var item in _kinematicTransformComponents) { + var currMatrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); // Check against main-thread cache - if (_ctx.MainThreadKinematicCache.TryGetValue(coll.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { + if (_ctx.MainThreadKinematicCache.TryGetValue(item.ItemId, out var lastMatrix) && lastMatrix.Equals(currMatrix)) { // unchanged — if it moved last frame, it just stopped, so stage a velocity reset - if (_movedKinematicItems.Remove(coll.ItemId)) { - _pendingKinematicStopUpdates.Add(coll.ItemId); + if (_movedKinematicItems.Remove(item.ItemId)) { + _pendingKinematicStopUpdates.Add(item.ItemId); } continue; } // Transform changed — update cache - _ctx.MainThreadKinematicCache[coll.ItemId] = currMatrix; - _movedKinematicItems.Add(coll.ItemId); - _pendingKinematicUpdates.Add(new KeyValuePair(coll.ItemId, currMatrix)); + _ctx.MainThreadKinematicCache[item.ItemId] = currMatrix; + _movedKinematicItems.Add(item.ItemId); + _pendingKinematicUpdates.Add(new KeyValuePair(item.ItemId, currMatrix)); } if (_pendingKinematicUpdates.Count == 0 && _pendingKinematicStopUpdates.Count == 0) { @@ -816,23 +817,23 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // check for updated kinematic transforms; compare against the last staged // pose — held or target — not the stepped current pose, which may lag // behind while catching up - foreach (var coll in _kinematicColliderComponents) { + foreach (var item in _kinematicTransformComponents) { float4x4 lastTransformationMatrix; - if (_heldIsolatedPoses.TryGetValue(coll.ItemId, out var held)) { + if (_heldIsolatedPoses.TryGetValue(item.ItemId, out var held)) { lastTransformationMatrix = held.Pose; - } else if (!_ctx.KinematicTargetTransforms.Ref.TryGetValue(coll.ItemId, out lastTransformationMatrix)) { - lastTransformationMatrix = _ctx.KinematicTransforms.Ref[coll.ItemId]; + } else if (!_ctx.KinematicTargetTransforms.Ref.TryGetValue(item.ItemId, out lastTransformationMatrix)) { + lastTransformationMatrix = _ctx.KinematicTransforms.Ref[item.ItemId]; } - var currTransformationMatrix = coll.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); + var currTransformationMatrix = item.GetLocalToPlayfieldMatrixInVpx(_worldToPlayfield); if (lastTransformationMatrix.Equals(currTransformationMatrix)) { // unchanged — if it moved last frame, it just stopped, so zero its velocity - if (_movedKinematicItems.Remove(coll.ItemId)) { - StopKinematicVelocity(coll.ItemId, _ctx.PhysicsEnv.CurPhysicsFrameTime); + if (_movedKinematicItems.Remove(item.ItemId)) { + StopKinematicVelocity(item.ItemId, _ctx.PhysicsEnv.CurPhysicsFrameTime); } continue; } - _movedKinematicItems.Add(coll.ItemId); - StageKinematicTarget(coll.ItemId, in currTransformationMatrix, _ctx.PhysicsEnv.CurPhysicsFrameTime); + _movedKinematicItems.Add(item.ItemId); + StageKinematicTarget(item.ItemId, in currTransformationMatrix, _ctx.PhysicsEnv.CurPhysicsFrameTime); } ProcessHeldKinematicPoses(_ctx.PhysicsEnv.CurPhysicsFrameTime); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs index a2aacea8c..7f8c83ecb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs @@ -177,11 +177,12 @@ internal static void StepKinematics(ref PhysicsState state) state.KinematicVelocities[itemId] = velocity; } - ref var colliderLookups = ref state.KinematicColliderLookups.GetValueByRef(itemId); - for (var i = 0; i < colliderLookups.Length; i++) { - state.TransformKinematicColliders(colliderLookups[i], current); - } - } + if (state.KinematicColliderLookups.TryGetValue(itemId, out var colliderLookups)) { + for (var i = 0; i < colliderLookups.Length; i++) { + state.TransformKinematicColliders(colliderLookups[i], current); + } + } + } PerfMarkerTransform.End(); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index d62880cb9..cd4e96723 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -158,7 +158,7 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ using (var enumerator = state.TurntableStates.GetEnumerator()) { while (enumerator.MoveNext()) { ref var turntableState = ref enumerator.Current.Value; - TurntablePhysics.Update(ref turntableState, ref state, physicsDiffTime); + TurntablePhysics.Update(enumerator.Current.Key, ref turntableState, ref state, physicsDiffTime); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs index 4ae450528..7b25f6ed8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs @@ -828,12 +828,11 @@ private static void DrawAabb(Aabb aabb, bool isSelected) #endregion - void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) - => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); - - int ICollidableComponent.ItemId => UnityObjectId.Get(MainComponent.gameObject); - bool ICollidableComponent.IsCollidable => isActiveAndEnabled; - } + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); + + bool ICollidableComponent.IsCollidable => isActiveAndEnabled; + } internal static class ColliderColor { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs index 1e2a87932..ddc45c851 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ICollidableComponent.cs @@ -18,49 +18,50 @@ namespace VisualPinball.Unity { - public interface ICollidableComponent + public interface IKinematicTransformComponent { - /// - /// Generates the colliders. - /// - /// - /// - /// - /// - /// - internal void GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, - float4x4 translateWithinPlayfieldMatrix, float margin); - /// /// The unique identifier of the main item. /// public int ItemId { get; } /// - /// Returns whether this specific item is set to collidable, i.e. whether can it ever be - /// collided with during gameplay. - /// - internal bool IsCollidable { get; } - - /// - /// If set, this collider can be transformed during gameplay. + /// If set, this item can be transformed during gameplay. /// public bool IsKinematic { get; } /// - /// The translation matrix, that will be applied in reverse to the ball - /// for hit testing and collision. + /// The item's local-to-playfield matrix in VPX coordinates. /// /// The playfield's worldToLocal matrix. /// public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield); /// - /// Executed on kinematic colliders, when the transformation has changed. This allows updating data if necessary, - /// for example the kicker center, which is relevant when spawning balls. + /// Executed on kinematic items when the transformation has changed. /// /// public void OnTransformationChanged(float4x4 currTransformationMatrix); + } + + public interface ICollidableComponent : IKinematicTransformComponent + { + /// + /// Generates the colliders. + /// + /// + /// + /// + /// + /// + internal void GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, + float4x4 translateWithinPlayfieldMatrix, float margin); + + /// + /// Returns whether this specific item is set to collidable, i.e. whether can it ever be + /// collided with during gameplay. + /// + internal bool IsCollidable { get; } public bool CollidersDirty { set; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index e6cbf4e0f..5a64b8e8b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -27,7 +27,7 @@ namespace VisualPinball.Unity [PackAs("Magnet")] [AddComponentMenu("Pinball/Mechs/Magnet")] [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] - public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDeviceComponent, IPackable + public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDeviceComponent, IPackable, IKinematicTransformComponent { public const string MagnetCoilItem = "magnet_coil"; public const string BallHeldSwitchItem = "ball_held"; @@ -63,6 +63,9 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("Whether the magnet starts enabled before coil or script control changes it.")] public bool IsEnabledOnStart; + [Tooltip("If set, transforming this object during gameplay moves the magnetic field with it.")] + public bool IsKinematic; + [Tooltip("Draw play-mode force vectors for balls inside the radius.")] public bool DrawDebugForces; @@ -136,6 +139,7 @@ internal MagnetState CreateState() GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), IsEnabled = IsEnabledOnStart, + IsKinematic = IsKinematic, Profile = ForceProfile, HeightRange = MillimetersToVpx(HeightRange), GrabbedBalls = default, @@ -157,6 +161,7 @@ private void SyncPhysicsState() var profile = ForceProfile; var heightRange = MillimetersToVpx(HeightRange); var planarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f); + var isKinematic = IsKinematic; _physicsEngine.MutateState((ref PhysicsState state) => { if (!state.MagnetStates.ContainsKey(itemId)) { @@ -170,11 +175,23 @@ private void SyncPhysicsState() magnet.Strength = strength; magnet.GrabRadius = grabRadius; magnet.PlanarDamping = planarDamping; + magnet.IsKinematic = isKinematic; magnet.Profile = profile; magnet.HeightRange = heightRange; }); } + public int ItemId => UnityObjectId.Get(gameObject); + + bool IKinematicTransformComponent.IsKinematic => IsKinematic; + + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) + => Physics.GetLocalToPlayfieldMatrixInVpx(transform.localToWorldMatrix, worldToPlayfield); + + public void OnTransformationChanged(float4x4 currTransformationMatrix) + { + } + internal static float MillimetersToVpx(float value) => Physics.ScaleToVpx(value * MillimetersToWorld); /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index 110f10400..9187baec7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -25,6 +25,7 @@ public struct MagnetPackable public float GrabRadius; public float HeightRange; public bool IsEnabledOnStart; + public bool IsKinematic; public bool DrawDebugForces; public static byte[] Pack(MagnetComponent comp) @@ -37,6 +38,7 @@ public static byte[] Pack(MagnetComponent comp) GrabRadius = comp.GrabRadius, HeightRange = comp.HeightRange, IsEnabledOnStart = comp.IsEnabledOnStart, + IsKinematic = comp.IsKinematic, DrawDebugForces = comp.DrawDebugForces, }); } @@ -51,6 +53,7 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) comp.GrabRadius = data.GrabRadius; comp.HeightRange = data.HeightRange; comp.IsEnabledOnStart = data.IsEnabledOnStart; + comp.IsKinematic = data.IsKinematic; comp.DrawDebugForces = data.DrawDebugForces; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 04c8561ea..3d1c9a6c0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -35,6 +35,8 @@ internal static class MagnetPhysics [BurstCompile] internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) { + var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); + if (!magnet.IsEnabled) { ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); if (!state.InsideOfs.IsEmpty(itemId)) { @@ -64,7 +66,7 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState } UpdateMembership(itemId, ball.Id, true, ref state); - if (UpdateGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime)) { + if (UpdateGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime, magnetVelocity)) { continue; } @@ -73,13 +75,29 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); break; case MagnetForceProfile.Physical: - ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime); + ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); break; } } } } + internal static float2 RefreshKinematicState(int itemId, ref MagnetState magnet, ref PhysicsState state) + { + if (!magnet.IsKinematic || !state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { + return float2.zero; + } + + ApplyKinematicTransform(ref magnet, in matrix); + return GetKinematicVelocity(itemId, in magnet, ref state); + } + + internal static void ApplyKinematicTransform(ref MagnetState magnet, in float4x4 matrix) + { + magnet.Position = matrix.c3.xy; + magnet.Height = matrix.c3.z; + } + internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) { if (magnet.GrabbedBalls.Value != 0UL) { @@ -142,6 +160,9 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState } internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + => ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, float2.zero); + + internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity) { var delta = ball.Position.xy - magnet.Position; var distanceSq = math.lengthsq(delta); @@ -156,29 +177,36 @@ internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magne var velocity = ball.Velocity.xy - direction * force * physicsDiffTime; var damping = math.saturate(math.abs(force) * PhysicalVelocityDamping * physicsDiffTime); - velocity *= 1f - damping; + velocity = magnetVelocity + (velocity - magnetVelocity) * (1f - damping); ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet) + => ApplyVpxCompatibleGrab(ref ball, in magnet, float2.zero); + + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet, float2 magnetVelocity) { ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); ball.EventPosition = new float3(magnet.Position.x, magnet.Position.y, ball.EventPosition.z); - ball.Velocity = new float3(0f, 0f, ball.Velocity.z); - ball.OldVelocity = new float3(0f, 0f, ball.OldVelocity.z); + ball.Velocity = new float3(magnetVelocity.x, magnetVelocity.y, ball.Velocity.z); + ball.OldVelocity = new float3(magnetVelocity.x, magnetVelocity.y, ball.OldVelocity.z); ball.AngularMomentum = float3.zero; } internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime) + => ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, float2.zero); + + internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity) { var offset = ball.Position.xy - magnet.Position; var velocity = ball.Velocity.xy; + var relativeVelocity = velocity - magnetVelocity; var holdStrength = math.max(math.abs(magnet.Strength), PhysicalMinimumHoldStrength); var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); - var impulse = (-offset * stiffness - velocity * damping) * physicsDiffTime; + var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; var maxImpulse = holdStrength * physicsDiffTime; var impulseLenSq = math.lengthsq(impulse); var maxImpulseSq = maxImpulse * maxImpulse; @@ -215,7 +243,7 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } - private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime) + private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float2 magnetVelocity) { // plain attraction magnets never grab; skip the bookkeeping entirely if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { @@ -246,10 +274,10 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt } switch (magnet.Profile) { case MagnetForceProfile.Physical: - ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime); + ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity); break; default: - ApplyVpxCompatibleGrab(ref ball, in magnet); + ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity); break; } return true; @@ -260,6 +288,27 @@ private static float PhysicalCoreRadius(in MagnetState magnet) return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); } + private static float2 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) + { + if (!state.KinematicVelocities.TryGetValue(itemId, out var velocity)) { + return float2.zero; + } + + var linear = velocity.LinearVelocity; + var angular = velocity.AngularVelocity; + if (math.lengthsq(velocity.StepVelocity) > math.lengthsq(linear) + || math.lengthsq(velocity.StepAngularVelocity) > math.lengthsq(angular)) { + linear = velocity.StepVelocity; + angular = velocity.StepAngularVelocity; + } + if (math.lengthsq(linear) < 1e-8f && math.lengthsq(angular) < 1e-8f) { + return float2.zero; + } + + var position = new float3(magnet.Position.x, magnet.Position.y, magnet.Height); + return (linear + math.cross(angular, position - velocity.Pivot)).xy; + } + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) { if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 8186d39e3..3069f5ff6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -30,6 +30,8 @@ internal struct MagnetState internal float PlanarDamping; [MarshalAs(UnmanagedType.U1)] internal bool IsEnabled; + [MarshalAs(UnmanagedType.U1)] + internal bool IsKinematic; internal MagnetForceProfile Profile; internal float HeightRange; internal BitField64 GrabbedBalls; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 86eba65dc..1d3f74cc9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -26,7 +26,7 @@ namespace VisualPinball.Unity [PackAs("Turntable")] [AddComponentMenu("Pinball/Mechs/Turntable")] [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] - public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable + public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable, IKinematicTransformComponent { public const string MotorCoilItem = "motor_coil"; public const string DirectionCoilItem = "direction_coil"; @@ -60,6 +60,9 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable [Tooltip("Initial spin direction.")] public bool SpinClockwise = true; + [Tooltip("If set, transforming this object during gameplay moves the turntable force field with it.")] + public bool IsKinematic; + [Tooltip("Optional visual disc to rotate with the simulated speed.")] public Transform RotationTarget; @@ -140,6 +143,7 @@ internal TurntableState CreateState() SpinDown = SpinDown, MotorOn = MotorOnStart, SpinClockwise = SpinClockwise, + IsKinematic = IsKinematic, RotationAngle = 0f, VisualSpeedFactor = VisualSpeedFactor }; @@ -147,6 +151,17 @@ internal TurntableState CreateState() return state; } + public int ItemId => UnityObjectId.Get(gameObject); + + bool IKinematicTransformComponent.IsKinematic => IsKinematic; + + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) + => Physics.GetLocalToPlayfieldMatrixInVpx(transform.localToWorldMatrix, worldToPlayfield); + + public void OnTransformationChanged(float4x4 currTransformationMatrix) + { + } + private void OnDrawGizmosSelected() { var radiusWorld = Radius * MagnetComponent.MillimetersToWorld; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs index 8cd3d1f08..a2cafa960 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePackable.cs @@ -45,6 +45,7 @@ public class TurntablePackable public float SpinDown; public bool MotorOnStart; public bool SpinClockwise; + public bool IsKinematic; public float VisualSpeedFactor; public static byte[] Pack(TurntableComponent comp) @@ -57,6 +58,7 @@ public static byte[] Pack(TurntableComponent comp) SpinDown = comp.SpinDown, MotorOnStart = comp.MotorOnStart, SpinClockwise = comp.SpinClockwise, + IsKinematic = comp.IsKinematic, VisualSpeedFactor = comp.VisualSpeedFactor }); } @@ -71,6 +73,7 @@ public static void Unpack(byte[] bytes, TurntableComponent comp) comp.SpinDown = data.SpinDown; comp.MotorOnStart = data.MotorOnStart; comp.SpinClockwise = data.SpinClockwise; + comp.IsKinematic = data.IsKinematic; comp.VisualSpeedFactor = data.VisualSpeedFactor; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index 6b0193e3a..356c67a80 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -26,8 +26,9 @@ internal static class TurntablePhysics private const float SecondsPerVpxUpdate = MagnetPhysics.VpxMagnetUpdateMs * 0.001f; [BurstCompile] - internal static void Update(ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) + internal static void Update(int itemId, ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) { + RefreshKinematicState(itemId, ref turntable, ref state); UpdateSpeed(ref turntable, physicsDiffTime); // Speed is a VPX-arbitrary force scale, not a rotation rate; VisualSpeedFactor // maps it to degrees per second for the visual disc. @@ -48,6 +49,19 @@ internal static void Update(ref TurntableState turntable, ref PhysicsState state } } + internal static void RefreshKinematicState(int itemId, ref TurntableState turntable, ref PhysicsState state) + { + if (turntable.IsKinematic && state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { + ApplyKinematicTransform(ref turntable, in matrix); + } + } + + internal static void ApplyKinematicTransform(ref TurntableState turntable, in float4x4 matrix) + { + turntable.Position = matrix.c3.xy; + turntable.Height = matrix.c3.z; + } + internal static void UpdateSpeed(ref TurntableState turntable, float physicsDiffTime) { var targetSpeed = turntable.MotorOn ? turntable.TargetSpeed : 0f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs index ba6cd2974..18e1eb7df 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableState.cs @@ -34,6 +34,8 @@ internal struct TurntableState internal bool MotorOn; [MarshalAs(UnmanagedType.U1)] internal bool SpinClockwise; + [MarshalAs(UnmanagedType.U1)] + internal bool IsKinematic; internal float RotationAngle; internal float VisualSpeedFactor; } From 541b2598f8d48782299689065fcb1303ee3c5ed6 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:22:22 +0200 Subject: [PATCH 25/50] physics(kinematics): guard duplicate kinematic item ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collider components resolve their item id from the main component's GameObject while magnets and turntables use their own, so two kinematic components on one GameObject silently overwrote each other in the transform-callback map — the losing component (e.g. a kicker keeping its capture center current) stopped receiving OnTransformationChanged. Warn at registration and name both components. Also make the startup log honest again: the kinematic count now includes colliderless transforms, so report kinematic colliders and kinematic transforms separately. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 2 +- .../VisualPinball.Unity/Game/PhysicsEngineThreading.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 95606484b..b4e70ceec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -1017,7 +1017,7 @@ private void Start() } // create static octree - Debug.Log($"Found {_colliderComponents.Length} collidable items ({_kinematicTransformComponents.Length} kinematic)."); + Debug.Log($"Found {_colliderComponents.Length} collidable items ({_colliderComponents.Count(c => c.IsKinematic)} kinematic) and {_kinematicTransformComponents.Length} kinematic transforms."); var colliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp); var kinematicColliders = new ColliderReference(ref _ctx.NonTransformableColliderTransforms.Ref, Allocator.Temp, true); foreach (var colliderItem in _colliderComponents) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 106d12b59..6881eb74d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -110,6 +110,9 @@ internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContex _kinematicTransformComponentsByItemId = new Dictionary(kinematicTransformComponents?.Length ?? 0); if (kinematicTransformComponents != null) { foreach (var item in kinematicTransformComponents) { + if (_kinematicTransformComponentsByItemId.TryGetValue(item.ItemId, out var existing)) { + Logger.Warn($"[PhysicsEngine] Kinematic components {Describe(existing)} and {Describe(item)} resolve to the same item id {item.ItemId} (shared GameObject). Only one of them receives transform updates and their kinematic motion will conflict — move one to its own child GameObject."); + } _kinematicTransformComponentsByItemId[item.ItemId] = item; } } @@ -468,6 +471,9 @@ private IKinematicTransformComponent GetKinematicTransformComponent(int itemId) return _kinematicTransformComponentsByItemId.TryGetValue(itemId, out var item) ? item : null; } + private static string Describe(IKinematicTransformComponent item) + => item is UnityEngine.MonoBehaviour mb ? $"\"{mb.name}\" ({item.GetType().Name})" : item.GetType().Name; + /// /// Copy current animation values from physics state maps into the /// given snapshot buffer. Must be allocation-free. From 898a92d83c8aa7743263d5c6cb4d765c05b291b6 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:23:17 +0200 Subject: [PATCH 26/50] physics(kinematics): skip octree rebuild for colliderless items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kinematic-octree dirty flag was set for every changed kinematic transform, so a continuously moving colliderless magnet or turntable forced RebuildOctree — swept AABBs for every kinematic collider on the table — each tick batch despite no collider having moved. Only mark the octree dirty when the item actually owns kinematic colliders; before colliderless items entered this path, the flag was justified by construction. --- .../VisualPinball.Unity/Game/PhysicsEngineThreading.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 6881eb74d..02bbf73d4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -358,7 +358,10 @@ private void StageKinematicTarget(int itemId, in float4x4 matrix, ulong nowUsec) // continuous motion (incl. resolving a hold): stream toward the target, // capped per tick, so a fast collider can't skip past a ball _ctx.KinematicTargetTransforms.Ref[itemId] = matrix; - _ctx.KinematicOctreeDirty = true; + // colliderless kinematic items (magnets, turntables) don't affect the octree + if (_ctx.KinematicColliderLookups.ContainsKey(itemId)) { + _ctx.KinematicOctreeDirty = true; + } } var item = GetKinematicTransformComponent(itemId); @@ -379,8 +382,8 @@ private void SnapKinematicPose(int itemId, in float4x4 matrix) for (var i = 0; i < colliderLookups.Length; i++) { state.TransformKinematicColliders(colliderLookups[i], matrix); } + _ctx.KinematicOctreeDirty = true; } - _ctx.KinematicOctreeDirty = true; } /// From 6e09ec655f3c2e183c9672a79844b329f79ea1d9 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:24:46 +0200 Subject: [PATCH 27/50] physics(magnets): hold grabbed balls through the height gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kinematic refresh tracks the transform's height, but grab carry is planar-only — a magnet on a lifting mech raised its height window past the held ball's z and silently released it mid-carry. A grabbed ball now stays held regardless of the range check; only the grab logic itself may release it. Adds a PhysicsState test harness so tests can drive the real update/state wiring, and documents that the carry is planar. --- .../manual/mechanisms/magnets.md | 2 +- .../Physics/MagnetPhysicsTests.cs | 103 ++++++++++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 12 +- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index b5c7b724d..45c7c92e6 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -75,7 +75,7 @@ The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back towa Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. Grabbed balls are carried with the magnet's planar velocity and keep that velocity when released. -Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned; tilted field axes are not modeled. +Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned; tilted field axes are not modeled. A grabbed ball stays held even when the height window moves past it, but the carry itself is planar — a vertically moving magnet does not lift the ball. ## Importing VPX Magnets diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 639589d65..43c761d2b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -14,13 +14,46 @@ // 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; namespace VisualPinball.Unity.Test { public class MagnetPhysicsTests { + [Test] + public void GrabbedBallSurvivesMovingHeightWindow() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + PlanarDamping = 0.985f, + HeightRange = 25f, + IsEnabled = true + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should be grabbed"); + + // the (kinematic) magnet moves up; the held ball must not be dropped + // when the height window leaves it behind + magnet.Height = 100f; + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should stay held"); + } + [Test] public void VpxCompatibleForceScalesToOneMillisecondTicks() { @@ -270,4 +303,74 @@ private static BallState CreateBall() }; } } + + /// + /// A minimal over hand-created containers, so + /// tests can drive the real update/state wiring instead of only the pure + /// force helpers. Containers a magnet/turntable update never touches stay + /// default. + /// + internal sealed class PhysicsStateHarness : IDisposable + { + internal NativeParallelHashMap Balls; + internal NativeParallelHashMap KinematicTransforms; + internal NativeParallelHashMap KinematicVelocities; + internal InsideOfs InsideOfs; + internal NativeQueue EventQueue; + + private PhysicsEnv _env; + private NativeOctree _octree; + private NativeColliders _colliders; + private NativeColliders _kinematicColliders; + private NativeColliders _kinematicCollidersAtIdentity; + private NativeParallelHashMap _kinematicTargetTransforms; + private NativeParallelHashMap _nonTransformableColliderTransforms; + private NativeParallelHashMap _kinematicColliderLookups; + 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; + + internal PhysicsStateHarness() + { + Balls = new NativeParallelHashMap(4, Allocator.Persistent); + KinematicTransforms = new NativeParallelHashMap(4, Allocator.Persistent); + KinematicVelocities = new NativeParallelHashMap(4, Allocator.Persistent); + InsideOfs = new InsideOfs(Allocator.Persistent); + EventQueue = new NativeQueue(Allocator.Persistent); + } + + internal PhysicsState CreateState() + { + var events = EventQueue.AsParallelWriter(); + return new PhysicsState(ref _env, ref _octree, ref _colliders, ref _kinematicColliders, + ref _kinematicCollidersAtIdentity, ref KinematicTransforms, ref _kinematicTargetTransforms, + ref _nonTransformableColliderTransforms, ref _kinematicColliderLookups, ref events, + 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() + { + Balls.Dispose(); + KinematicTransforms.Dispose(); + KinematicVelocities.Dispose(); + InsideOfs.Dispose(); + EventQueue.Dispose(); + } + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 3d1c9a6c0..4565161c2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -57,7 +57,7 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState continue; } - var affectsBall = IsBallInRange(in ball, in magnet); + var affectsBall = IsBallInRange(in ball, in magnet) || IsGrabbedBall(in magnet, ref state, ball.Id); if (!affectsBall) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); ClearReleasedBall(ref magnet, ref state, ball.Id); @@ -232,6 +232,16 @@ internal static void ApplyPlanarEject(ref BallState ball, float speed, float ang ball.AngularMomentum = float3.zero; } + /// + /// A grabbed ball stays held even when the range check no longer covers + /// it — e.g. a kinematic magnet whose height window moves past the ball + /// it is carrying. Only the grab logic itself may release it. + /// + private static bool IsGrabbedBall(in MagnetState magnet, ref PhysicsState state, int ballId) + => magnet.GrabbedBalls.Value != 0UL + && state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex) + && magnet.GrabbedBalls.IsSet(bitIndex); + internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) { if (magnet.Radius <= 0f) { From 2fb9a48fa6dc79a6c5675cfedf0db6d8a3e0a881 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:25:24 +0200 Subject: [PATCH 28/50] physics(magnets): inherit carrier velocity on eject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eject() overwrote the ball velocity absolutely, erasing the kinematic carrier velocity the grab path maintains — firing from a moving magnet (the T2 cannon case) behaved as if the magnet were stationary, unlike releasing the coil mid-move. Add the magnet's kinematic velocity to the eject impulse; zero for stationary magnets. --- .../Physics/MagnetPhysicsTests.cs | 11 +++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 14 +++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 43c761d2b..cb9976bfe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -294,6 +294,17 @@ public void PlanarEjectUsesKickerAngleConvention() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void PlanarEjectAddsCarrierVelocity() + { + var ball = CreateBall(); + + MagnetPhysics.ApplyPlanarEject(ref ball, 20f, 90f, new float2(5f, -3f)); + + Assert.That(ball.Velocity.x, Is.EqualTo(25f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(-3f).Within(1e-5f)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 4565161c2..dfd7f5338 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -122,6 +122,14 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, float speed, float angleDeg) { + if (magnet.GrabbedBalls.Value == 0UL) { + return; + } + + // a ball thrown from a moving magnet keeps the carrier velocity, same + // as releasing the coil mid-move does + var carrierVelocity = GetKinematicVelocity(itemId, in magnet, ref state); + for (var bitIndex = 0; bitIndex < 64; bitIndex++) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { continue; @@ -129,7 +137,7 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { if (state.Balls.ContainsKey(ballId)) { ref var ball = ref state.Balls.GetValueByRef(ballId); - ApplyPlanarEject(ref ball, speed, angleDeg); + ApplyPlanarEject(ref ball, speed, angleDeg, carrierVelocity); } state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } @@ -220,10 +228,10 @@ internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); } - internal static void ApplyPlanarEject(ref BallState ball, float speed, float angleDeg) + internal static void ApplyPlanarEject(ref BallState ball, float speed, float angleDeg, float2 carrierVelocity = default) { var angleRad = math.radians(angleDeg); - var velocity = new float2( + var velocity = carrierVelocity + new float2( math.sin(angleRad) * speed, -math.cos(angleRad) * speed ); From 017117ff3d7045f76456f7f2af0381fd4e8b1711 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:25:45 +0200 Subject: [PATCH 29/50] editor(magnets): lock kinematic toggle during play Kinematic registration and transform seeding happen once at startup, so toggling Is Kinematic in play mode flipped only the state flag: enabling was a silent no-op (never seeded or scanned) and disabling left the scan running against a frozen field. Disable the checkbox during play instead of pretending it works. --- .../VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs | 5 ++++- .../VPT/Turntable/TurntableInspector.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index 6e8dba011..b4182b7a6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -67,7 +67,10 @@ public override void OnInspectorGUI() EditorGUILayout.Space(8f); PropertyField(_isEnabledOnStartProperty); - PropertyField(_isKinematicProperty); + // kinematic registration is fixed at startup; toggling during play would silently do nothing + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_isKinematicProperty); + } PropertyField(_drawDebugForcesProperty); base.OnInspectorGUI(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs index 4884a0663..53d42887a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Turntable/TurntableInspector.cs @@ -65,7 +65,10 @@ public override void OnInspectorGUI() EditorGUILayout.Space(8f); PropertyField(_motorOnStartProperty); PropertyField(_spinClockwiseProperty); - PropertyField(_isKinematicProperty); + // kinematic registration is fixed at startup; toggling during play would silently do nothing + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_isKinematicProperty); + } PropertyField(_rotationTargetProperty); PropertyField(_visualSpeedFactorProperty); From 05ef9721c0558db07eea74dcf10b1204e03b5d89 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:26:11 +0200 Subject: [PATCH 30/50] runtime(turntables): sync inspector changes at runtime The inspector-sync fix covered only magnets, so play-mode edits of turntable fields silently kept the boot-time values while the magnet next to it responded immediately. Mirror the sync: rebuild the state from the authored fields on OnValidate, preserving the runtime-owned motor, direction, speed and rotation angle, and recompute the target speed the way the API setters do. --- .../VPT/Turntable/TurntableComponent.cs | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 1d3f74cc9..35a8de685 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -72,6 +72,7 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable public TurntableApi TurntableApi { get; private set; } + private PhysicsEngine _physicsEngine; private Transform _rotationTarget; private Quaternion _rotationTargetInitialRotation; @@ -106,17 +107,54 @@ private void Awake() return; } - var physicsEngine = GetComponentInParent(); - TurntableApi = new TurntableApi(gameObject, player, physicsEngine); + _physicsEngine = GetComponentInParent(); + TurntableApi = new TurntableApi(gameObject, player, _physicsEngine); player.Register(TurntableApi, this); - if (physicsEngine) { - physicsEngine.Register(this); + if (_physicsEngine) { + _physicsEngine.Register(this); } else { Logger.Error($"Cannot find physics engine for turntable {name}."); } } + private void OnValidate() + { + Radius = math.max(0f, Radius); + HeightRange = math.max(0f, HeightRange); + SpinUp = math.max(0f, SpinUp); + SpinDown = math.max(0f, SpinDown); + VisualSpeedFactor = math.max(0f, VisualSpeedFactor); + SyncPhysicsState(); + } + + /// + /// Pushes inspector edits to the live physics state during play mode. + /// Builds a fresh state from the authored fields and preserves the + /// runtime-owned ones (motor, direction, current speed and angle). + /// + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = ItemId; + var synced = CreateState(); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.TurntableStates.ContainsKey(itemId)) { + return; + } + ref var turntable = ref state.TurntableStates.GetValueByRef(itemId); + synced.Speed = turntable.Speed; + synced.MotorOn = turntable.MotorOn; + synced.SpinClockwise = turntable.SpinClockwise; + synced.RotationAngle = turntable.RotationAngle; + TurntablePhysics.RefreshTargetSpeed(ref synced); + turntable = synced; + }); + } + private void Update() { if (!RotationTarget || TurntableApi == null) { From 04afe4f0e838fc3d0a7f196055c1433bca1284ab Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:26:36 +0200 Subject: [PATCH 31/50] magnets: collapse state sync into create state SyncPhysicsState re-derived every converted field of CreateState by hand, so any new magnet parameter would import correctly but silently stop syncing from the inspector. Build the synced state through CreateState and preserve only the runtime-owned fields (coil state and grab bookkeeping), matching the turntable sync. --- .../VPT/Magnet/MagnetComponent.cs | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 5a64b8e8b..ba8beaeb7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -147,37 +147,28 @@ internal MagnetState CreateState() }; } + /// + /// Pushes inspector edits to the live physics state during play mode. + /// Builds a fresh state from the authored fields and preserves the + /// runtime-owned ones (coil state and grab bookkeeping). + /// private void SyncPhysicsState() { if (!Application.isPlaying || !_physicsEngine) { return; } - var itemId = UnityObjectId.Get(gameObject); - var pos = GetPlayfieldPositionVpx(transform); - var radius = MillimetersToVpx(Radius); - var strength = Strength; - var grabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f; - var profile = ForceProfile; - var heightRange = MillimetersToVpx(HeightRange); - var planarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f); - var isKinematic = IsKinematic; - + var itemId = ItemId; + var synced = CreateState(); _physicsEngine.MutateState((ref PhysicsState state) => { if (!state.MagnetStates.ContainsKey(itemId)) { return; } - ref var magnet = ref state.MagnetStates.GetValueByRef(itemId); - magnet.Position = pos.xy; - magnet.Height = pos.z; - magnet.Radius = radius; - magnet.Strength = strength; - magnet.GrabRadius = grabRadius; - magnet.PlanarDamping = planarDamping; - magnet.IsKinematic = isKinematic; - magnet.Profile = profile; - magnet.HeightRange = heightRange; + synced.IsEnabled = magnet.IsEnabled; + synced.GrabbedBalls = magnet.GrabbedBalls; + synced.ReleasedBalls = magnet.ReleasedBalls; + magnet = synced; }); } From 902ef924c0e2512ca11d14ac0785d6e00552bbec Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:27:03 +0200 Subject: [PATCH 32/50] runtime(turntables): restore rotation baseline on retarget Re-capturing the visual baseline from a disc that was already driven baked the accumulated spin into the baseline, compounding an angular offset on every retarget and leaving the old disc frozen mid-spin. Restore the previous target to its authored pose before capturing the new one, so retargeting round-trips cleanly. --- .../VPT/Turntable/TurntableComponent.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 35a8de685..329106ce8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -157,14 +157,24 @@ private void SyncPhysicsState() private void Update() { - if (!RotationTarget || TurntableApi == null) { + if (TurntableApi == null) { return; } if (_rotationTarget != RotationTarget) { + // restore the previous disc to its authored pose, so a re-captured + // baseline never contains accumulated simulated spin + if (_rotationTarget) { + _rotationTarget.localRotation = _rotationTargetInitialRotation; + } _rotationTarget = RotationTarget; - _rotationTargetInitialRotation = RotationTarget.localRotation; + if (_rotationTarget) { + _rotationTargetInitialRotation = _rotationTarget.localRotation; + } + } + if (!_rotationTarget) { + return; } - RotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); + _rotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); } internal TurntableState CreateState() From 7cf0a702fa3a2e76b75eaa67e5451f1573baad3e Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:27:40 +0200 Subject: [PATCH 33/50] physics(kinematics): share kinematic point velocity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The magnet's kinematic velocity duplicated the step-substitution core of GetKinematicSurfaceVelocity token for token — load-bearing anti-swallow logic that would silently drift apart when tuned. Extract an item-level GetKinematicVelocityAt on PhysicsState and let both the collider surface path and the magnet field path use it. --- .../VisualPinball.Unity/Game/PhysicsState.cs | 101 +++++++++++------- .../VPT/Magnet/MagnetPhysics.cs | 17 +-- 2 files changed, 63 insertions(+), 55 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index b9b474bac..43776bb74 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -135,15 +135,15 @@ internal struct PhysicsState internal NativeParallelHashMap BumperStates; internal NativeParallelHashMap DropTargetStates; internal NativeParallelHashMap FlipperStates; - internal NativeParallelHashMap GateStates; - internal NativeParallelHashMap HitTargetStates; - internal NativeParallelHashMap KickerStates; - internal NativeParallelHashMap MagnetStates; - internal NativeParallelHashMap PlungerStates; - internal NativeParallelHashMap SpinnerStates; - internal NativeParallelHashMap SurfaceStates; - internal NativeParallelHashMap TurntableStates; - internal NativeParallelHashMap TriggerStates; + internal NativeParallelHashMap GateStates; + internal NativeParallelHashMap HitTargetStates; + internal NativeParallelHashMap KickerStates; + internal NativeParallelHashMap MagnetStates; + internal NativeParallelHashMap PlungerStates; + internal NativeParallelHashMap SpinnerStates; + internal NativeParallelHashMap SurfaceStates; + internal NativeParallelHashMap TurntableStates; + internal NativeParallelHashMap TriggerStates; internal NativeParallelHashSet DisabledCollisionItems; [MarshalAs(UnmanagedType.U1)] @@ -156,13 +156,13 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native ref NativeParallelHashMap nonTransformableColliderTransforms, ref NativeParallelHashMap kinematicColliderLookups, ref NativeQueue.ParallelWriter eventQueue, ref InsideOfs insideOfs, ref NativeParallelHashMap balls, - ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, - ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, - ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, - ref NativeParallelHashMap magnetStates, - ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, - ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, - ref NativeParallelHashMap triggerStates, + ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, + ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, + ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, + ref NativeParallelHashMap magnetStates, + ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, + ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, + ref NativeParallelHashMap triggerStates, ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling, ref NativeParallelHashMap> elasticityOverVelocityLUTs, ref NativeParallelHashMap> frictionOverVelocityLUTs, @@ -183,15 +183,15 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native BumperStates = bumperStates; DropTargetStates = dropTargetStates; FlipperStates = flipperStates; - GateStates = gateStates; - HitTargetStates = hitTargetStates; - KickerStates = kickerStates; - MagnetStates = magnetStates; - PlungerStates = plungerStates; - SpinnerStates = spinnerStates; - SurfaceStates = surfaceStates; - TurntableStates = turntableStates; - TriggerStates = triggerStates; + GateStates = gateStates; + HitTargetStates = hitTargetStates; + KickerStates = kickerStates; + MagnetStates = magnetStates; + PlungerStates = plungerStates; + SpinnerStates = spinnerStates; + SurfaceStates = surfaceStates; + TurntableStates = turntableStates; + TriggerStates = triggerStates; DisabledCollisionItems = disabledCollisionItems; SwapBallCollisionHandling = swapBallCollisionHandling; @@ -309,29 +309,52 @@ internal float3 GetKinematicSurfaceVelocity(ref NativeColliders colliders, int c return float3.zero; } var itemId = colliders.GetItemId(colliderId); - if (!KinematicVelocities.TryGetValue(itemId, out var velocity)) { + if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { return float3.zero; } + if (colliders.IsTransformed(colliderId)) { + return linear + math.cross(angular, position - pivot); + } + ref var matrix = ref KinematicTransforms.GetValueByRef(itemId); + var velocityAtPosition = linear + math.cross(angular, matrix.MultiplyPoint(position) - pivot); + return math.inverse(matrix).MultiplyVector(velocityAtPosition); + } + + /// + /// Velocity of a kinematic item at a given playfield-space point, + /// including the step-rate substitution while the pose catch-up is + /// rate-limited. Zero for unknown or resting items. Used by force + /// fields (magnets) whose held balls move with the item. + /// + internal float3 GetKinematicVelocityAt(int itemId, in float3 position) + { + if (!TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) { + return float3.zero; + } + return linear + math.cross(angular, position - pivot); + } + + private bool TryGetKinematicVelocity(int itemId, out float3 linear, out float3 angular, out float3 pivot) + { + if (!KinematicVelocities.TryGetValue(itemId, out var velocity)) { + linear = float3.zero; + angular = float3.zero; + pivot = float3.zero; + return false; + } + // while the pose catch-up is rate-limited (clamped stepping), the actual // step rate is the honest surface velocity and can exceed the derived one - var linear = velocity.LinearVelocity; - var angular = velocity.AngularVelocity; + linear = velocity.LinearVelocity; + angular = velocity.AngularVelocity; + pivot = velocity.Pivot; if (math.lengthsq(velocity.StepVelocity) > math.lengthsq(linear) || math.lengthsq(velocity.StepAngularVelocity) > math.lengthsq(angular)) { linear = velocity.StepVelocity; angular = velocity.StepAngularVelocity; } - if (math.lengthsq(linear) < 1e-8f && math.lengthsq(angular) < 1e-8f) { - return float3.zero; - } - - if (colliders.IsTransformed(colliderId)) { - return linear + math.cross(angular, position - velocity.Pivot); - } - ref var matrix = ref KinematicTransforms.GetValueByRef(itemId); - var velocityAtPosition = linear + math.cross(angular, matrix.MultiplyPoint(position) - velocity.Pivot); - return math.inverse(matrix).MultiplyVector(velocityAtPosition); + return math.lengthsq(linear) >= 1e-8f || math.lengthsq(angular) >= 1e-8f; } /// @@ -447,4 +470,4 @@ private bool IsInactiveDropTarget(ref NativeColliders colliders, int colliderId) #endregion } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index dfd7f5338..a7887ac81 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -308,23 +308,8 @@ private static float PhysicalCoreRadius(in MagnetState magnet) private static float2 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) { - if (!state.KinematicVelocities.TryGetValue(itemId, out var velocity)) { - return float2.zero; - } - - var linear = velocity.LinearVelocity; - var angular = velocity.AngularVelocity; - if (math.lengthsq(velocity.StepVelocity) > math.lengthsq(linear) - || math.lengthsq(velocity.StepAngularVelocity) > math.lengthsq(angular)) { - linear = velocity.StepVelocity; - angular = velocity.StepAngularVelocity; - } - if (math.lengthsq(linear) < 1e-8f && math.lengthsq(angular) < 1e-8f) { - return float2.zero; - } - var position = new float3(magnet.Position.x, magnet.Position.y, magnet.Height); - return (linear + math.cross(angular, position - velocity.Pivot)).xy; + return state.GetKinematicVelocityAt(itemId, position).xy; } private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) From ee03b188c644c7e36a87234cfdca9aff09f8cefb Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:28:54 +0200 Subject: [PATCH 34/50] magnets: defer kinematic refresh and trim overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabled kinematic magnets paid two hashmap lookups and velocity math per tick for a result the early-out discarded; refresh the pose after the enabled check instead. Replace the three test-only zero-velocity forwarding overloads with defaulted parameters so no caller can silently drop the carrier velocity, scan the hierarchy once at startup (every collidable is a kinematic transform component), and cover the kinematic gating and velocity plumbing with wiring tests — the code paths both review findings lived in. --- .../Physics/MagnetPhysicsTests.cs | 57 +++++++++++++++++++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 6 +- .../VPT/Magnet/MagnetPhysics.cs | 20 ++----- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index cb9976bfe..1e2205a2d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -294,6 +294,63 @@ public void PlanarEjectUsesKickerAngleConvention() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void KinematicRefreshFollowsTransformOnlyWhenKinematicAndSeeded() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + + var kinematic = new MagnetState { IsKinematic = true }; + MagnetPhysics.RefreshKinematicState(1, ref kinematic, ref state); + Assert.That(kinematic.Position, Is.EqualTo(new float2(120f, 80f))); + Assert.That(kinematic.Height, Is.EqualTo(30f).Within(1e-5f)); + + var nonKinematic = new MagnetState { Position = new float2(5f, 5f) }; + MagnetPhysics.RefreshKinematicState(1, ref nonKinematic, ref state); + Assert.That(nonKinematic.Position, Is.EqualTo(new float2(5f, 5f)), "non-kinematic magnets must not follow the transform"); + + var unseeded = new MagnetState { IsKinematic = true, Position = new float2(5f, 5f) }; + MagnetPhysics.RefreshKinematicState(99, ref unseeded, ref state); + Assert.That(unseeded.Position, Is.EqualTo(new float2(5f, 5f)), "unseeded items must keep their baked position"); + } + + [Test] + public void KinematicRefreshDerivesVelocityFromStateMaps() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + harness.KinematicVelocities.Add(1, new KinematicVelocityState { + LinearVelocity = new float3(2f, -1f, 0f), + Pivot = new float3(120f, 80f, 30f) + }); + + var magnet = new MagnetState { IsKinematic = true }; + var velocity = MagnetPhysics.RefreshKinematicState(1, ref magnet, ref state); + + Assert.That(velocity.x, Is.EqualTo(2f).Within(1e-5f)); + Assert.That(velocity.y, Is.EqualTo(-1f).Within(1e-5f)); + } + + [Test] + public void KinematicRefreshSubstitutesStepVelocityDuringCatchUp() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.KinematicTransforms.Add(1, float4x4.Translate(new float3(120f, 80f, 30f))); + harness.KinematicVelocities.Add(1, new KinematicVelocityState { + LinearVelocity = new float3(1f, 0f, 0f), + StepVelocity = new float3(3f, 0f, 0f), + Pivot = new float3(120f, 80f, 30f) + }); + + var magnet = new MagnetState { IsKinematic = true }; + var velocity = MagnetPhysics.RefreshKinematicState(1, ref magnet, ref state); + + Assert.That(velocity.x, Is.EqualTo(3f).Within(1e-5f), "rate-limited catch-up must expose the step rate"); + } + [Test] public void PlanarEjectAddsCarrierVelocity() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index b4e70ceec..083aaaf3d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -990,8 +990,10 @@ private void Awake() _ctx.ElasticityOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); _ctx.FrictionOverVelocityLUTs = new NativeParallelHashMap>(0, Allocator.Persistent); - _colliderComponents = GetComponentsInChildren(); - _kinematicTransformComponents = GetComponentsInChildren().Where(c => c.IsKinematic).ToArray(); + // one hierarchy walk: every collidable is also a kinematic transform component + var kinematicTransformComponents = GetComponentsInChildren(); + _colliderComponents = kinematicTransformComponents.OfType().ToArray(); + _kinematicTransformComponents = kinematicTransformComponents.Where(c => c.IsKinematic).ToArray(); } private void Start() diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index a7887ac81..d85a310e2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -35,8 +35,6 @@ internal static class MagnetPhysics [BurstCompile] internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) { - var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); - if (!magnet.IsEnabled) { ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); if (!state.InsideOfs.IsEmpty(itemId)) { @@ -45,6 +43,9 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState return; } + // disabled magnets don't need the pose; the first enabled tick refreshes it + var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); + // constant within the tick; hoisted so the transcendental isn't paid per ball var vpxDamping = math.pow(magnet.PlanarDamping, physicsDiffTime); @@ -167,10 +168,7 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } - internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime) - => ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, float2.zero); - - internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity) + internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) { var delta = ball.Position.xy - magnet.Position; var distanceSq = math.lengthsq(delta); @@ -190,10 +188,7 @@ internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magne ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } - internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet) - => ApplyVpxCompatibleGrab(ref ball, in magnet, float2.zero); - - internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet, float2 magnetVelocity) + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet, float2 magnetVelocity = default) { ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); ball.EventPosition = new float3(magnet.Position.x, magnet.Position.y, ball.EventPosition.z); @@ -202,10 +197,7 @@ internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState m ball.AngularMomentum = float3.zero; } - internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime) - => ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, float2.zero); - - internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity) + internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) { var offset = ball.Position.xy - magnet.Position; var velocity = ball.Velocity.xy; From b130dcbed5b240bf2cde344d093d3d9bfc65b945 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:33:42 +0200 Subject: [PATCH 35/50] runtime(turntables): import collections extensions GetValueByRef in the new inspector sync is an extension method from VisualPinball.Unity.Collections. --- .../VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 329106ce8..01826a337 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -19,6 +19,7 @@ using Unity.Mathematics; using UnityEngine; using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Collections; using Logger = NLog.Logger; namespace VisualPinball.Unity From b98796b7ff68754b0d4ad9127ca5325fa69ba849 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 10:33:43 +0200 Subject: [PATCH 36/50] test(magnets): reference native trees The PhysicsState test harness holds a NativeOctree field for the state constructor, which lives in the com.bartofzo.nativetrees assembly. --- .../VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef | 1 + 1 file changed, 1 insertion(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef index b5e7e0a2c..19148d705 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef @@ -7,6 +7,7 @@ "Unity.Entities.Hybrid", "Unity.Mathematics", "Unity.Transforms", + "com.bartofzo.nativetrees", "VisualPinball.Engine", "VisualPinball.Engine.Test", "VisualPinball.Unity", From 0ac8e83d1bebd63b44022ec04938b34aa8f48bfb Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 14:13:11 +0200 Subject: [PATCH 37/50] runtime(magnets): add spatial magnet mode Support 3-D magnet attraction and rigid grab/carry behavior for mech-mounted ball pickups, including vertical ejects, editor/docs coverage, and focused physics tests. --- .../manual/mechanisms/magnets.md | 25 ++- .../VPT/Magnet/MagnetInspector.cs | 15 +- .../Physics/MagnetPhysicsTests.cs | 174 ++++++++++++++++++ .../VPT/Magnet/MagnetApi.cs | 4 +- .../VPT/Magnet/MagnetComponent.cs | 32 +++- .../VPT/Magnet/MagnetForceProfile.cs | 6 + .../VPT/Magnet/MagnetPackable.cs | 3 + .../VPT/Magnet/MagnetPhysics.cs | 153 +++++++++++++-- .../VPT/Magnet/MagnetState.cs | 1 + 9 files changed, 380 insertions(+), 33 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index 45c7c92e6..bd18e8be2 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -12,18 +12,19 @@ Use **Magnet** for radial attraction, grab/hold behavior, repulsion, and eject-s ## Magnet Setup -Add the **Magnet** component to a GameObject with *Add Component -> Pinball -> Mechs -> Magnet*. Place the GameObject at the magnet core position on the playfield. The component uses the transform position as the center of the force field. +Add the **Magnet** component to a GameObject with *Add Component -> Pinball -> Mechs -> Magnet*. Place the GameObject at the magnet core or hold position. The component uses the transform position as the center of the force field. -The selected object shows a flat radius gizmo in the scene view. If grab is enabled, a smaller grab radius is drawn as well. +The selected object shows a radius gizmo in the scene view. Playfield magnets draw a cylinder; spatial magnets draw a sphere. If grab is enabled, a smaller grab radius is drawn as well. | Field | Description | |---|---| -| **Radius** | Planar influence radius in millimeters. Balls outside this radius are ignored. | -| **Height Range** | Vertical window above the magnet surface. Use this to avoid affecting balls on ramps above the playfield. | +| **Magnet Type** | **Playfield** for under-playfield magnets with a cylindrical range, **Spatial** for mech-mounted magnets that grab and carry balls in 3-D. | +| **Radius** | Influence radius in millimeters. Playfield magnets use a planar radius; spatial magnets use a spherical radius. | +| **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. Spatial magnets ignore this field. | | **Strength** | Magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values. Negative values repel. | -| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. | +| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. Spatial magnets always use physical 3-D force semantics. | | **Grab Ball** | Enables center hold behavior inside the grab radius. | -| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible mode clamps to center; Physical mode uses a spring-damper hold. | +| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible playfield mode clamps to center; Physical playfield mode uses a spring-damper hold; Spatial mode freezes the ball at the 3-D hold point. | | **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | | **Is Kinematic** | Moves the magnetic field with the GameObject transform during gameplay. Use this when the magnet is mounted on a moving mech. | | **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | @@ -58,6 +59,14 @@ Use **Physical** for new VPE-authored tables. It uses a saturated inverse-square Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. +## Spatial Magnets + +Use **Magnet Type: Spatial** for mechanisms that physically carry the ball away from the playfield, such as a mouth, hand, or wand mounted on a moving mech. Spatial magnets use a spherical radius around the transform and treat the transform as the ball center hold point. + +When **Grab Ball** is enabled, a ball inside **Grab Radius** snaps to that 3-D hold point and is frozen while held. If **Is Kinematic** is enabled and the GameObject moves during gameplay, the held ball follows in x, y, and z. Turning the coil off or calling `ReleaseBall()` unfreezes the ball with the mech's current 3-D carrier velocity. `Eject(speed, angleDeg, verticalAngleDeg)` can add a directional throw; the first angle uses the same convention as kickers, and the optional vertical angle lifts or drops the shot. + +Spatial magnets are not a levitation model. Use the radius and strength to catch a nearby ball, then rely on grab-and-carry for the stable mechanical hold. + ## Turntable Setup Add the **Turntable** component with *Add Component -> Pinball -> Mechs -> Turntable*. Place it at the disc center and set **Radius** to cover the area where the spinning disc should affect the ball. @@ -73,9 +82,9 @@ The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back towa ## Kinematic Magnets -Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. Grabbed balls are carried with the magnet's planar velocity and keep that velocity when released. +Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. Playfield magnets carry grabbed balls with the magnet's planar velocity; spatial magnets carry grabbed balls with the full 3-D velocity. -Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned; tilted field axes are not modeled. A grabbed ball stays held even when the height window moves past it, but the carry itself is planar — a vertically moving magnet does not lift the ball. +Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned for playfield magnets; tilted field axes are not modeled. Spatial magnets are the supported path for lifting a held ball away from the playfield. ## Importing VPX Magnets diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index b4182b7a6..cee21dfcb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -24,6 +24,7 @@ public class MagnetInspector : ItemInspector { private SerializedProperty _radiusProperty; private SerializedProperty _strengthProperty; + private SerializedProperty _magnetTypeProperty; private SerializedProperty _forceProfileProperty; private SerializedProperty _grabBallProperty; private SerializedProperty _grabRadiusProperty; @@ -40,6 +41,7 @@ protected override void OnEnable() _radiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.Radius)); _strengthProperty = serializedObject.FindProperty(nameof(MagnetComponent.Strength)); + _magnetTypeProperty = serializedObject.FindProperty(nameof(MagnetComponent.MagnetType)); _forceProfileProperty = serializedObject.FindProperty(nameof(MagnetComponent.ForceProfile)); _grabBallProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabBall)); _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); @@ -54,10 +56,19 @@ public override void OnInspectorGUI() BeginEditing(); OnPreInspectorGUI(); + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_magnetTypeProperty); + } + var isSpatial = _magnetTypeProperty.enumValueIndex == (int)MagnetType.Spatial; + PropertyField(_radiusProperty); - PropertyField(_heightRangeProperty); + if (!isSpatial) { + PropertyField(_heightRangeProperty); + } PropertyField(_strengthProperty); - PropertyField(_forceProfileProperty); + if (!isSpatial) { + PropertyField(_forceProfileProperty); + } EditorGUILayout.Space(8f); PropertyField(_grabBallProperty); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 1e2205a2d..756a17c48 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -176,6 +176,44 @@ public void PhysicalForceRepelsWithNegativeStrength() Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); } + [Test] + public void SpatialRangeUsesSphericalDistance() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 30f); + var playfieldMagnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 40f, + HeightRange = 10f + }; + var spatialMagnet = playfieldMagnet; + spatialMagnet.MagnetType = MagnetType.Spatial; + + Assert.That(MagnetPhysics.IsBallInRange(in ball, in playfieldMagnet), Is.False); + Assert.That(MagnetPhysics.IsBallInRange(in ball, in spatialMagnet), Is.True); + } + + [Test] + public void SpatialPhysicalForcePullsInThreeDimensions() + { + var ball = CreateBall(); + ball.Position = new float3(0f, 0f, 50f); + var magnet = new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + Strength = 400f, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.ApplySpatialPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.LessThan(0f)); + } + [Test] public void VpxCompatibleGrabClampsBallToMagnetCenter() { @@ -240,6 +278,74 @@ public void PhysicalHoldPullsBallWithoutTeleporting() Assert.That(ball.AngularMomentum.y, Is.LessThan(1f)); } + [Test] + public void SpatialGrabFreezesAndClampsBallToHoldPoint() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(5f, -2f, 14f), + EventPosition = new float3(5f, -2f, 14f), + Velocity = new float3(1f, 2f, 3f), + OldVelocity = new float3(4f, 5f, 6f), + AngularMomentum = new float3(1f, 0f, 0f) + }); + var magnet = new MagnetState { + Position = new float2(4f, -3f), + Height = 12f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + PlanarDamping = 0.985f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + var center = MagnetPhysics.Center3D(in magnet); + Assert.That(ball.IsFrozen, Is.True); + Assert.That(ball.Position, Is.EqualTo(center)); + Assert.That(ball.EventPosition, Is.EqualTo(center)); + Assert.That(ball.Velocity, Is.EqualTo(float3.zero)); + Assert.That(ball.OldVelocity, Is.EqualTo(float3.zero)); + Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + } + + [Test] + public void SpatialGrabbedBallFollowsMovingHoldPoint() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + PlanarDamping = 0.985f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + magnet.Position = new float2(40f, -25f); + magnet.Height = 70f; + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + Assert.That(ball.IsFrozen, Is.True); + Assert.That(ball.Position, Is.EqualTo(new float3(40f, -25f, 70f))); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + } + [Test] public void PhysicalHoldDampsRelativeToKinematicMagnetVelocity() { @@ -294,6 +400,37 @@ public void PlanarEjectUsesKickerAngleConvention() Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); } + [Test] + public void SpatialEjectAddsVerticalAngleAndUnfreezes() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f), + IsFrozen = true + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + MagnetType = MagnetType.Spatial + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + MagnetPhysics.EjectGrabbedBalls(17, ref magnet, ref state, 20f, 90f, 30f); + + var ball = harness.Balls[1]; + Assert.That(ball.IsFrozen, Is.False); + Assert.That(ball.Velocity.x, Is.EqualTo(20f * math.cos(math.radians(30f))).Within(1e-5f)); + Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(ball.Velocity.z, Is.EqualTo(10f).Within(1e-5f)); + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + } + [Test] public void KinematicRefreshFollowsTransformOnlyWhenKinematicAndSeeded() { @@ -331,6 +468,7 @@ public void KinematicRefreshDerivesVelocityFromStateMaps() Assert.That(velocity.x, Is.EqualTo(2f).Within(1e-5f)); Assert.That(velocity.y, Is.EqualTo(-1f).Within(1e-5f)); + Assert.That(velocity.z, Is.EqualTo(0f).Within(1e-5f)); } [Test] @@ -362,6 +500,42 @@ public void PlanarEjectAddsCarrierVelocity() Assert.That(ball.Velocity.y, Is.EqualTo(-3f).Within(1e-5f)); } + [Test] + public void SpatialReleaseUnfreezesWithCarrierVelocity() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(0f, 0f, 10f), + IsFrozen = true + }); + harness.KinematicTransforms.Add(17, float4x4.Translate(new float3(30f, -10f, 60f))); + harness.KinematicVelocities.Add(17, new KinematicVelocityState { + LinearVelocity = new float3(3f, -2f, 5f), + Pivot = new float3(30f, -10f, 60f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + IsKinematic = true, + MagnetType = MagnetType.Spatial + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + Assert.That(ball.IsFrozen, Is.False); + Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f))); + Assert.That(ball.OldVelocity, Is.EqualTo(new float3(3f, -2f, 5f))); + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 2c274e116..7d5b9e849 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -105,7 +105,7 @@ public void ReleaseBall() }); } - public void Eject(float speed, float angleDeg) + public void Eject(float speed, float angleDeg, float verticalAngleDeg = 0f) { if (!_physicsEngine) { return; @@ -115,7 +115,7 @@ public void Eject(float speed, float angleDeg) return; } ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); - MagnetPhysics.EjectGrabbedBalls(_itemId, ref magnet, ref state, speed, angleDeg); + MagnetPhysics.EjectGrabbedBalls(_itemId, ref magnet, ref state, speed, angleDeg, verticalAngleDeg); }); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index ba8beaeb7..576f7d706 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -44,6 +44,9 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("Magnet strength. In VPX-compatible mode this uses cvpmMagnet strength values.")] public float Strength = 10f; + [Tooltip("Playfield magnets act through a vertical cylinder; spatial magnets grab and carry balls in 3-D.")] + public MagnetType MagnetType = VisualPinball.Unity.MagnetType.Playfield; + [Tooltip("How the authored strength value is interpreted.")] public MagnetForceProfile ForceProfile = MagnetForceProfile.VpxCompatible; @@ -140,8 +143,9 @@ internal MagnetState CreateState() PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), IsEnabled = IsEnabledOnStart, IsKinematic = IsKinematic, - Profile = ForceProfile, + Profile = MagnetType == VisualPinball.Unity.MagnetType.Spatial ? MagnetForceProfile.Physical : ForceProfile, HeightRange = MillimetersToVpx(HeightRange), + MagnetType = MagnetType, GrabbedBalls = default, ReleasedBalls = default }; @@ -205,14 +209,22 @@ private void OnDrawGizmosSelected() var heightRangeWorld = HeightRange * MillimetersToWorld; Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.9f); - DrawLocalDisc(radiusWorld); + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + Gizmos.DrawWireSphere(transform.position, radiusWorld); + } else { + DrawLocalDisc(radiusWorld); + } if (GrabBall && grabRadiusWorld > 0f) { Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.5f); - DrawLocalDisc(grabRadiusWorld); + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + Gizmos.DrawWireSphere(transform.position, grabRadiusWorld); + } else { + DrawLocalDisc(grabRadiusWorld); + } } - if (heightRangeWorld > 0f) { + if (MagnetType == VisualPinball.Unity.MagnetType.Playfield && heightRangeWorld > 0f) { Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.35f); DrawLocalCylinder(radiusWorld, heightRangeWorld); } @@ -226,9 +238,15 @@ private void OnDrawGizmosSelected() } foreach (var ball in player.GetComponentsInChildren()) { var offset = ball.transform.position - transform.position; - var planarOffset = Vector3.ProjectOnPlane(offset, transform.up); - if (planarOffset.sqrMagnitude <= radiusWorld * radiusWorld) { - Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * math.min(radiusWorld * 0.25f, planarOffset.magnitude)); + if (MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + if (offset.sqrMagnitude <= radiusWorld * radiusWorld) { + Gizmos.DrawLine(ball.transform.position, ball.transform.position - offset.normalized * math.min(radiusWorld * 0.25f, offset.magnitude)); + } + } else { + var planarOffset = Vector3.ProjectOnPlane(offset, transform.up); + if (planarOffset.sqrMagnitude <= radiusWorld * radiusWorld) { + Gizmos.DrawLine(ball.transform.position, ball.transform.position - planarOffset.normalized * math.min(radiusWorld * 0.25f, planarOffset.magnitude)); + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs index 0cd08dfc6..ae4ac8c06 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetForceProfile.cs @@ -21,4 +21,10 @@ public enum MagnetForceProfile VpxCompatible = 0, Physical = 1 } + + public enum MagnetType + { + Playfield = 0, + Spatial = 1 + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index 9187baec7..ca8693ec5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -20,6 +20,7 @@ public struct MagnetPackable { public float Radius; public float Strength; + public MagnetType MagnetType; public MagnetForceProfile ForceProfile; public bool GrabBall; public float GrabRadius; @@ -33,6 +34,7 @@ public static byte[] Pack(MagnetComponent comp) return PackageApi.Packer.Pack(new MagnetPackable { Radius = comp.Radius, Strength = comp.Strength, + MagnetType = comp.MagnetType, ForceProfile = comp.ForceProfile, GrabBall = comp.GrabBall, GrabRadius = comp.GrabRadius, @@ -48,6 +50,7 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) var data = PackageApi.Packer.Unpack(bytes); comp.Radius = data.Radius; comp.Strength = data.Strength; + comp.MagnetType = data.MagnetType; comp.ForceProfile = data.ForceProfile; comp.GrabBall = data.GrabBall; comp.GrabRadius = data.GrabRadius; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index d85a310e2..38527bc36 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -43,7 +43,7 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState return; } - // disabled magnets don't need the pose; the first enabled tick refreshes it + // keep the field pose fresh for enabled magnets before forces are applied var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); // constant within the tick; hoisted so the transcendental isn't paid per ball @@ -52,13 +52,14 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; - if (ball.IsFrozen) { + var isGrabbed = IsGrabbedBall(in magnet, ref state, ball.Id); + if (ball.IsFrozen && !(magnet.MagnetType == MagnetType.Spatial && isGrabbed)) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); UpdateMembership(itemId, ball.Id, false, ref state); continue; } - var affectsBall = IsBallInRange(in ball, in magnet) || IsGrabbedBall(in magnet, ref state, ball.Id); + var affectsBall = IsBallInRange(in ball, in magnet) || isGrabbed; if (!affectsBall) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); ClearReleasedBall(ref magnet, ref state, ball.Id); @@ -71,22 +72,27 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState continue; } + if (magnet.MagnetType == MagnetType.Spatial) { + ApplySpatialPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); + continue; + } + switch (magnet.Profile) { case MagnetForceProfile.VpxCompatible: ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); break; case MagnetForceProfile.Physical: - ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); + ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); break; } } } } - internal static float2 RefreshKinematicState(int itemId, ref MagnetState magnet, ref PhysicsState state) + internal static float3 RefreshKinematicState(int itemId, ref MagnetState magnet, ref PhysicsState state) { if (!magnet.IsKinematic || !state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { - return float2.zero; + return float3.zero; } ApplyKinematicTransform(ref magnet, in matrix); @@ -99,8 +105,12 @@ internal static void ApplyKinematicTransform(ref MagnetState magnet, in float4x4 magnet.Height = matrix.c3.z; } - internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) + internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab, float3 carrierVelocity = default) { + if (magnet.MagnetType == MagnetType.Spatial) { + carrierVelocity = RefreshKinematicState(itemId, ref magnet, ref state); + } + if (magnet.GrabbedBalls.Value != 0UL) { for (var bitIndex = 0; bitIndex < 64; bitIndex++) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { @@ -111,6 +121,7 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref magnet.ReleasedBalls.SetBits(bitIndex, true); } if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { + ReleaseSpatialBallIfNeeded(ref magnet, ref state, ballId, carrierVelocity); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } } @@ -121,7 +132,7 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref } } - internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, float speed, float angleDeg) + internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, float speed, float angleDeg, float verticalAngleDeg = 0f) { if (magnet.GrabbedBalls.Value == 0UL) { return; @@ -138,7 +149,11 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { if (state.Balls.ContainsKey(ballId)) { ref var ball = ref state.Balls.GetValueByRef(ballId); - ApplyPlanarEject(ref ball, speed, angleDeg, carrierVelocity); + if (magnet.MagnetType == MagnetType.Spatial) { + ApplySpatialEject(ref ball, speed, angleDeg, verticalAngleDeg, carrierVelocity); + } else { + ApplyPlanarEject(ref ball, speed, angleDeg, carrierVelocity.xy); + } } state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } @@ -188,6 +203,26 @@ internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magne ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); } + internal static void ApplySpatialPhysicalForce(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float3 magnetVelocity = default) + { + var delta = ball.Position - Center3D(in magnet); + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinDistanceSq) { + return; + } + + var distance = math.sqrt(distanceSq); + var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); + var force = magnet.Strength / (effectiveDistance * effectiveDistance); + var direction = delta / distance; + var velocity = ball.Velocity - direction * force * physicsDiffTime; + + var damping = math.saturate(math.abs(force) * PhysicalVelocityDamping * physicsDiffTime); + velocity = magnetVelocity + (velocity - magnetVelocity) * (1f - damping); + + ball.Velocity = velocity; + } + internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState magnet, float2 magnetVelocity = default) { ball.Position = new float3(magnet.Position.x, magnet.Position.y, ball.Position.z); @@ -197,6 +232,17 @@ internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState m ball.AngularMomentum = float3.zero; } + internal static void ApplySpatialGrab(ref BallState ball, in MagnetState magnet, float3 magnetVelocity = default) + { + var center = Center3D(in magnet); + ball.Position = center; + ball.EventPosition = center; + ball.Velocity = magnetVelocity; + ball.OldVelocity = magnetVelocity; + ball.AngularMomentum = float3.zero; + ball.IsFrozen = true; + } + internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) { var offset = ball.Position.xy - magnet.Position; @@ -232,6 +278,28 @@ internal static void ApplyPlanarEject(ref BallState ball, float speed, float ang ball.AngularMomentum = float3.zero; } + internal static void ApplySpatialEject(ref BallState ball, float speed, float angleDeg, float verticalAngleDeg, float3 carrierVelocity = default) + { + var angleRad = math.radians(angleDeg); + var verticalRad = math.radians(verticalAngleDeg); + var horizontalSpeed = speed * math.cos(verticalRad); + var velocity = carrierVelocity + new float3( + math.sin(angleRad) * horizontalSpeed, + -math.cos(angleRad) * horizontalSpeed, + math.sin(verticalRad) * speed + ); + + ball.IsFrozen = false; + ball.Velocity = velocity; + ball.OldVelocity = velocity; + ball.AngularMomentum = float3.zero; + } + + internal static float3 Center3D(in MagnetState magnet) + { + return new float3(magnet.Position.x, magnet.Position.y, magnet.Height); + } + /// /// A grabbed ball stays held even when the range check no longer covers /// it — e.g. a kinematic magnet whose height window moves past the ball @@ -247,13 +315,23 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) if (magnet.Radius <= 0f) { return false; } + if (magnet.MagnetType == MagnetType.Spatial) { + return math.lengthsq(ball.Position - Center3D(in magnet)) <= magnet.Radius * magnet.Radius; + } if (magnet.HeightRange > 0f && (ball.Position.z < magnet.Height || ball.Position.z > magnet.Height + magnet.HeightRange)) { return false; } return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } - private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float2 magnetVelocity) + private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float3 magnetVelocity) + { + return magnet.MagnetType == MagnetType.Spatial + ? UpdateSpatialGrab(itemId, ref magnet, ref state, ref ball, magnetVelocity) + : UpdatePlayfieldGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime, magnetVelocity.xy); + } + + private static bool UpdatePlayfieldGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float2 magnetVelocity) { // plain attraction magnets never grab; skip the bookkeeping entirely if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { @@ -293,15 +371,49 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt return true; } + private static bool UpdateSpatialGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float3 magnetVelocity) + { + if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { + return false; + } + + var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); + var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); + if (magnet.GrabRadius <= 0f || magnet.Strength <= 0f) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + if (isGrabbed) { + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ball.Id, ref state, false, magnetVelocity); + } + return false; + } + + var center = Center3D(in magnet); + var isInGrabRange = isGrabbed || math.lengthsq(ball.Position - center) <= magnet.GrabRadius * magnet.GrabRadius; + if (!isInGrabRange) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + return false; + } + + if (magnet.ReleasedBalls.IsSet(bitIndex)) { + return false; + } + + if (!isGrabbed) { + magnet.GrabbedBalls.SetBits(bitIndex, true); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); + } + ApplySpatialGrab(ref ball, in magnet, magnetVelocity); + return true; + } + private static float PhysicalCoreRadius(in MagnetState magnet) { return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); } - private static float2 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) + private static float3 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) { - var position = new float3(magnet.Position.x, magnet.Position.y, magnet.Height); - return state.GetKinematicVelocityAt(itemId, position).xy; + return state.GetKinematicVelocityAt(itemId, Center3D(in magnet)); } private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) @@ -312,7 +424,7 @@ private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref P ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, false); } - private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab, float3 carrierVelocity = default) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { return; @@ -321,9 +433,22 @@ private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int b if (suppressRegrab) { magnet.ReleasedBalls.SetBits(bitIndex, true); } + ReleaseSpatialBallIfNeeded(ref magnet, ref state, ballId, carrierVelocity); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } + private static void ReleaseSpatialBallIfNeeded(ref MagnetState magnet, ref PhysicsState state, int ballId, float3 carrierVelocity) + { + if (magnet.MagnetType != MagnetType.Spatial || !state.Balls.ContainsKey(ballId)) { + return; + } + ref var ball = ref state.Balls.GetValueByRef(ballId); + ball.IsFrozen = false; + ball.Velocity = carrierVelocity; + ball.OldVelocity = carrierVelocity; + ball.AngularMomentum = float3.zero; + } + private static void ClearReleasedBall(ref MagnetState magnet, ref PhysicsState state, int ballId) { if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 3069f5ff6..4ae52f4aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -34,6 +34,7 @@ internal struct MagnetState internal bool IsKinematic; internal MagnetForceProfile Profile; internal float HeightRange; + internal MagnetType MagnetType; internal BitField64 GrabbedBalls; internal BitField64 ReleasedBalls; } From 8dd6c644741b3bce2881cc548df7e2942164a55a Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 15:13:59 +0200 Subject: [PATCH 38/50] physics(magnets): hold spatial balls with force, not freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spatial magnet held its ball by freezing it (BallState.IsFrozen) and clamping its position each tick. Reusing IsFrozen — which the engine treats as visually static and collision-exempt — for a moving carried ball made the ball invisible (transform sync skips frozen balls), sink one radius (kicker-hole render hack), and let other balls phase through it, and the hold could never be overcome. Hold the ball with a strong 3-D critically-damped spring instead (the spatial analog of ApplyPhysicalHold), leaving it a live physics object: it renders and collides normally, and because the spring impulse is capped at Strength*dt, a hard enough hit knocks it loose — the ball then leaves the grab radius and is released, and the ball that hit it can be grabbed in turn. Release is now just dropping the hold (the ball keeps its live velocity), so spatial and playfield release collapse to one path, and the two grab methods unify into one. Also fixes the stale pose on kinematic eject, drops the dead per-tick vpxDamping and Profile coercion for spatial magnets, and rewrites the spatial tests for the force model (grab-without-freeze, moving-hold tracking, knock-loose release, coil-off release). --- .../manual/mechanisms/magnets.md | 10 +- .../Physics/MagnetPhysicsTests.cs | 101 ++++++---- .../VPT/Magnet/MagnetComponent.cs | 3 +- .../VPT/Magnet/MagnetPhysics.cs | 180 ++++++++---------- 4 files changed, 145 insertions(+), 149 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index bd18e8be2..818220724 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -24,7 +24,7 @@ The selected object shows a radius gizmo in the scene view. Playfield magnets dr | **Strength** | Magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values. Negative values repel. | | **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. Spatial magnets always use physical 3-D force semantics. | | **Grab Ball** | Enables center hold behavior inside the grab radius. | -| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible playfield mode clamps to center; Physical playfield mode uses a spring-damper hold; Spatial mode freezes the ball at the 3-D hold point. | +| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible playfield mode clamps to center; Physical playfield mode uses a spring-damper hold; Spatial mode uses a 3-D spring-damper hold that a hard hit can overcome. | | **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | | **Is Kinematic** | Moves the magnetic field with the GameObject transform during gameplay. Use this when the magnet is mounted on a moving mech. | | **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | @@ -63,9 +63,9 @@ Physical strength values are not VPX strength values. Start with a larger value Use **Magnet Type: Spatial** for mechanisms that physically carry the ball away from the playfield, such as a mouth, hand, or wand mounted on a moving mech. Spatial magnets use a spherical radius around the transform and treat the transform as the ball center hold point. -When **Grab Ball** is enabled, a ball inside **Grab Radius** snaps to that 3-D hold point and is frozen while held. If **Is Kinematic** is enabled and the GameObject moves during gameplay, the held ball follows in x, y, and z. Turning the coil off or calling `ReleaseBall()` unfreezes the ball with the mech's current 3-D carrier velocity. `Eject(speed, angleDeg, verticalAngleDeg)` can add a directional throw; the first angle uses the same convention as kickers, and the optional vertical angle lifts or drops the shot. +When **Grab Ball** is enabled, a ball inside **Grab Radius** is pulled to that 3-D hold point by a strong magnetic force and held there. The ball stays a live physics object throughout — it is not frozen — so it renders at its real position and other balls collide with it normally. If **Is Kinematic** is enabled and the GameObject moves during gameplay, the hold force drags the ball along in x, y, and z. Turning the coil off or calling `ReleaseBall()` simply drops the hold, and the ball continues with whatever velocity it has. `Eject(speed, angleDeg, verticalAngleDeg)` throws it directionally; the first angle uses the same convention as kickers, and the optional vertical angle lifts or drops the shot. -Spatial magnets are not a levitation model. Use the radius and strength to catch a nearby ball, then rely on grab-and-carry for the stable mechanical hold. +Because the hold is a force rather than a rigid lock, a hard enough hit from another ball can overcome it and knock the ball loose (and that ball can in turn be grabbed). **Strength** sets how hard the magnet holds — a stronger magnet needs a harder hit to dislodge its ball. This is not a levitation model: use **Radius** and **Strength** to catch a nearby ball, not to pull one across the table or balance it far below the hold point. ## Turntable Setup @@ -82,9 +82,9 @@ The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back towa ## Kinematic Magnets -Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. Playfield magnets carry grabbed balls with the magnet's planar velocity; spatial magnets carry grabbed balls with the full 3-D velocity. +Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. A held ball is dragged along by the hold force — planar for playfield magnets, full 3-D for spatial magnets. -Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned for playfield magnets; tilted field axes are not modeled. Spatial magnets are the supported path for lifting a held ball away from the playfield. +Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned for playfield magnets; tilted field axes are not modeled. Spatial magnets are the supported path for carrying a held ball away from the playfield. ## Importing VPX Magnets diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 756a17c48..37e1e486d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -279,17 +279,14 @@ public void PhysicalHoldPullsBallWithoutTeleporting() } [Test] - public void SpatialGrabFreezesAndClampsBallToHoldPoint() + public void SpatialGrabHoldsBallWithForceNotFreeze() { using var harness = new PhysicsStateHarness(); var state = harness.CreateState(); harness.Balls.Add(1, new BallState { Id = 1, Position = new float3(5f, -2f, 14f), - EventPosition = new float3(5f, -2f, 14f), - Velocity = new float3(1f, 2f, 3f), - OldVelocity = new float3(4f, 5f, 6f), - AngularMomentum = new float3(1f, 0f, 0f) + Velocity = float3.zero }); var magnet = new MagnetState { Position = new float2(4f, -3f), @@ -297,7 +294,6 @@ public void SpatialGrabFreezesAndClampsBallToHoldPoint() Radius = 100f, Strength = 20f, GrabRadius = 20f, - PlanarDamping = 0.985f, IsEnabled = true, MagnetType = MagnetType.Spatial }; @@ -305,24 +301,24 @@ public void SpatialGrabFreezesAndClampsBallToHoldPoint() MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); var ball = harness.Balls[1]; - var center = MagnetPhysics.Center3D(in magnet); - Assert.That(ball.IsFrozen, Is.True); - Assert.That(ball.Position, Is.EqualTo(center)); - Assert.That(ball.EventPosition, Is.EqualTo(center)); - Assert.That(ball.Velocity, Is.EqualTo(float3.zero)); - Assert.That(ball.OldVelocity, Is.EqualTo(float3.zero)); - Assert.That(ball.AngularMomentum, Is.EqualTo(float3.zero)); - Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + var offset = new float3(5f, -2f, 14f) - MagnetPhysics.Center3D(in magnet); + // the ball stays a live physics object; the hold is a force, not a freeze + Assert.That(ball.IsFrozen, Is.False, "a spatial magnet holds with a force, it must not freeze the ball"); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball should be grabbed"); + // the ball started at rest, so its velocity is the hold impulse, which pulls + // toward the hold point (opposes the offset) + Assert.That(math.dot(ball.Velocity, offset), Is.LessThan(0f), "the hold must pull the ball toward the hold point"); } [Test] - public void SpatialGrabbedBallFollowsMovingHoldPoint() + public void SpatialHoldTracksMovingHoldPoint() { using var harness = new PhysicsStateHarness(); var state = harness.CreateState(); harness.Balls.Add(1, new BallState { Id = 1, - Position = new float3(0f, 0f, 10f) + Position = new float3(0f, 0f, 10f), + Velocity = float3.zero }); var magnet = new MagnetState { Position = float2.zero, @@ -330,20 +326,56 @@ public void SpatialGrabbedBallFollowsMovingHoldPoint() Radius = 100f, Strength = 20f, GrabRadius = 20f, - PlanarDamping = 0.985f, IsEnabled = true, MagnetType = MagnetType.Spatial }; MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); - magnet.Position = new float2(40f, -25f); - magnet.Height = 70f; + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + + // move the hold point; the held ball (still near the origin in the harness, + // which does not integrate displacement) is pulled toward the new point + magnet.Position = new float2(8f, -5f); + magnet.Height = 16f; MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); var ball = harness.Balls[1]; - Assert.That(ball.IsFrozen, Is.True); - Assert.That(ball.Position, Is.EqualTo(new float3(40f, -25f, 70f))); - Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + var offset = ball.Position - MagnetPhysics.Center3D(in magnet); + Assert.That(ball.IsFrozen, Is.False); + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL), "ball stays held as the point moves"); + Assert.That(math.dot(ball.Velocity, offset), Is.LessThan(0f), "the hold pulls toward the moved hold point"); + } + + [Test] + public void SpatialGrabReleasesBallKnockedOutsideGrabRadius() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + // the ball sits inside the outer radius but well outside the grab radius, as + // if a hard hit pushed it out of the hold — it must be released, because the + // hold is a force that can be overcome, not a rigid lock + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(50f, 0f, 10f), + Velocity = new float3(60f, 0f, 0f) + }); + var magnet = new MagnetState { + Position = float2.zero, + Height = 10f, + Radius = 100f, + Strength = 20f, + GrabRadius = 20f, + IsEnabled = true, + MagnetType = MagnetType.Spatial + }; + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + var ball = harness.Balls[1]; + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL), "a ball knocked outside the grab radius is released"); + Assert.That(ball.IsFrozen, Is.False); } [Test] @@ -401,14 +433,13 @@ public void PlanarEjectUsesKickerAngleConvention() } [Test] - public void SpatialEjectAddsVerticalAngleAndUnfreezes() + public void SpatialEjectAddsVerticalAngle() { using var harness = new PhysicsStateHarness(); var state = harness.CreateState(); harness.Balls.Add(1, new BallState { Id = 1, - Position = new float3(0f, 0f, 10f), - IsFrozen = true + Position = new float3(0f, 0f, 10f) }); var magnet = new MagnetState { Position = float2.zero, @@ -424,7 +455,6 @@ public void SpatialEjectAddsVerticalAngleAndUnfreezes() MagnetPhysics.EjectGrabbedBalls(17, ref magnet, ref state, 20f, 90f, 30f); var ball = harness.Balls[1]; - Assert.That(ball.IsFrozen, Is.False); Assert.That(ball.Velocity.x, Is.EqualTo(20f * math.cos(math.radians(30f))).Within(1e-5f)); Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); Assert.That(ball.Velocity.z, Is.EqualTo(10f).Within(1e-5f)); @@ -501,19 +531,14 @@ public void PlanarEjectAddsCarrierVelocity() } [Test] - public void SpatialReleaseUnfreezesWithCarrierVelocity() + public void SpatialCoilOffReleasesHeldBall() { using var harness = new PhysicsStateHarness(); var state = harness.CreateState(); harness.Balls.Add(1, new BallState { Id = 1, Position = new float3(0f, 0f, 10f), - IsFrozen = true - }); - harness.KinematicTransforms.Add(17, float4x4.Translate(new float3(30f, -10f, 60f))); - harness.KinematicVelocities.Add(17, new KinematicVelocityState { - LinearVelocity = new float3(3f, -2f, 5f), - Pivot = new float3(30f, -10f, 60f) + Velocity = new float3(3f, -2f, 5f) }); var magnet = new MagnetState { Position = float2.zero, @@ -521,19 +546,19 @@ public void SpatialReleaseUnfreezesWithCarrierVelocity() Radius = 100f, Strength = 20f, GrabRadius = 20f, - IsKinematic = true, - MagnetType = MagnetType.Spatial + MagnetType = MagnetType.Spatial, + IsEnabled = false }; var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); magnet.GrabbedBalls.SetBits(bitIndex, true); + // coil off -> the disabled magnet releases its ball, which keeps its velocity MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); var ball = harness.Balls[1]; - Assert.That(ball.IsFrozen, Is.False); - Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f))); - Assert.That(ball.OldVelocity, Is.EqualTo(new float3(3f, -2f, 5f))); Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + Assert.That(ball.IsFrozen, Is.False); + Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f)), "a released ball keeps its live velocity"); } private static BallState CreateBall() diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 576f7d706..07813ca8e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -143,7 +143,8 @@ internal MagnetState CreateState() PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), IsEnabled = IsEnabledOnStart, IsKinematic = IsKinematic, - Profile = MagnetType == VisualPinball.Unity.MagnetType.Spatial ? MagnetForceProfile.Physical : ForceProfile, + // spatial magnets dispatch on MagnetType and never read Profile + Profile = ForceProfile, HeightRange = MillimetersToVpx(HeightRange), MagnetType = MagnetType, GrabbedBalls = default, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 38527bc36..05183cdcb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -46,21 +46,24 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState // keep the field pose fresh for enabled magnets before forces are applied var magnetVelocity = RefreshKinematicState(itemId, ref magnet, ref state); - // constant within the tick; hoisted so the transcendental isn't paid per ball - var vpxDamping = math.pow(magnet.PlanarDamping, physicsDiffTime); + // only the VPX-compatible playfield path damps planar velocity; skip the + // transcendental for spatial and physical magnets that never read it + var vpxDamping = magnet.MagnetType == MagnetType.Playfield && magnet.Profile == MagnetForceProfile.VpxCompatible + ? math.pow(magnet.PlanarDamping, physicsDiffTime) + : 0f; using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; - var isGrabbed = IsGrabbedBall(in magnet, ref state, ball.Id); - if (ball.IsFrozen && !(magnet.MagnetType == MagnetType.Spatial && isGrabbed)) { + // a ball frozen by something else (e.g. a kicker capture) is off-limits; + // spatial magnets hold with a force, not a freeze, so they never freeze a ball + if (ball.IsFrozen) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); UpdateMembership(itemId, ball.Id, false, ref state); continue; } - var affectsBall = IsBallInRange(in ball, in magnet) || isGrabbed; - if (!affectsBall) { + if (!IsBallInRange(in ball, in magnet)) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); ClearReleasedBall(ref magnet, ref state, ball.Id); UpdateMembership(itemId, ball.Id, false, ref state); @@ -72,17 +75,19 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState continue; } - if (magnet.MagnetType == MagnetType.Spatial) { - ApplySpatialPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); - continue; - } - - switch (magnet.Profile) { - case MagnetForceProfile.VpxCompatible: - ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); + switch (magnet.MagnetType) { + case MagnetType.Spatial: + ApplySpatialPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity); break; - case MagnetForceProfile.Physical: - ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); + default: + switch (magnet.Profile) { + case MagnetForceProfile.VpxCompatible: + ApplyVpxCompatibleForce(ref ball, in magnet, physicsDiffTime, vpxDamping); + break; + case MagnetForceProfile.Physical: + ApplyPhysicalForce(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); + break; + } break; } } @@ -105,12 +110,10 @@ internal static void ApplyKinematicTransform(ref MagnetState magnet, in float4x4 magnet.Height = matrix.c3.z; } - internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab, float3 carrierVelocity = default) + internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) { - if (magnet.MagnetType == MagnetType.Spatial) { - carrierVelocity = RefreshKinematicState(itemId, ref magnet, ref state); - } - + // the ball is a live physics object throughout the hold, so releasing it is + // just dropping the hold force — it keeps whatever velocity it currently has if (magnet.GrabbedBalls.Value != 0UL) { for (var bitIndex = 0; bitIndex < 64; bitIndex++) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { @@ -121,7 +124,6 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref magnet.ReleasedBalls.SetBits(bitIndex, true); } if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { - ReleaseSpatialBallIfNeeded(ref magnet, ref state, ballId, carrierVelocity); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } } @@ -138,9 +140,10 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P return; } - // a ball thrown from a moving magnet keeps the carrier velocity, same - // as releasing the coil mid-move does - var carrierVelocity = GetKinematicVelocity(itemId, in magnet, ref state); + // a ball thrown from a moving magnet keeps the carrier velocity, same as + // releasing the coil mid-move does; refresh the pose first so a kinematic + // magnet ejected between ticks uses its current hold point, not last tick's + var carrierVelocity = RefreshKinematicState(itemId, ref magnet, ref state); for (var bitIndex = 0; bitIndex < 64; bitIndex++) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { @@ -232,15 +235,32 @@ internal static void ApplyVpxCompatibleGrab(ref BallState ball, in MagnetState m ball.AngularMomentum = float3.zero; } - internal static void ApplySpatialGrab(ref BallState ball, in MagnetState magnet, float3 magnetVelocity = default) + /// + /// 3-D critically-damped spring pulling the ball toward the moving hold point. + /// The ball stays a live physics object — it renders and collides normally, and + /// the capped impulse means a hard enough hit from another ball overcomes the + /// hold and knocks it loose (the next tick it leaves the grab radius and is + /// released). This is the spatial analog of . + /// + internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float3 magnetVelocity = default) { - var center = Center3D(in magnet); - ball.Position = center; - ball.EventPosition = center; - ball.Velocity = magnetVelocity; - ball.OldVelocity = magnetVelocity; - ball.AngularMomentum = float3.zero; - ball.IsFrozen = true; + var offset = ball.Position - Center3D(in magnet); + var relativeVelocity = ball.Velocity - magnetVelocity; + var holdStrength = math.max(math.abs(magnet.Strength), PhysicalMinimumHoldStrength); + var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); + var stiffness = holdStrength / holdRadius; + var damping = 2f * math.sqrt(stiffness); + var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; + var maxImpulse = holdStrength * physicsDiffTime; + var impulseLenSq = math.lengthsq(impulse); + var maxImpulseSq = maxImpulse * maxImpulse; + + if (impulseLenSq > maxImpulseSq && impulseLenSq > MinDistanceSq) { + impulse *= maxImpulse / math.sqrt(impulseLenSq); + } + + ball.Velocity += impulse; + ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); } internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet, float physicsDiffTime, float2 magnetVelocity = default) @@ -289,7 +309,6 @@ internal static void ApplySpatialEject(ref BallState ball, float speed, float an math.sin(verticalRad) * speed ); - ball.IsFrozen = false; ball.Velocity = velocity; ball.OldVelocity = velocity; ball.AngularMomentum = float3.zero; @@ -300,16 +319,6 @@ internal static float3 Center3D(in MagnetState magnet) return new float3(magnet.Position.x, magnet.Position.y, magnet.Height); } - /// - /// A grabbed ball stays held even when the range check no longer covers - /// it — e.g. a kinematic magnet whose height window moves past the ball - /// it is carrying. Only the grab logic itself may release it. - /// - private static bool IsGrabbedBall(in MagnetState magnet, ref PhysicsState state, int ballId) - => magnet.GrabbedBalls.Value != 0UL - && state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex) - && magnet.GrabbedBalls.IsSet(bitIndex); - internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) { if (magnet.Radius <= 0f) { @@ -324,14 +333,13 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } + /// + /// Shared grab bookkeeping for both magnet kinds. The only differences are the + /// distance metric (spherical for spatial, planar for playfield) and which hold + /// is applied. Because the hold is a force, a ball knocked out of the grab + /// radius simply fails the range check next tick and is released. + /// private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float3 magnetVelocity) - { - return magnet.MagnetType == MagnetType.Spatial - ? UpdateSpatialGrab(itemId, ref magnet, ref state, ref ball, magnetVelocity) - : UpdatePlayfieldGrab(itemId, ref magnet, ref state, ref ball, physicsDiffTime, magnetVelocity.xy); - } - - private static bool UpdatePlayfieldGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float2 magnetVelocity) { // plain attraction magnets never grab; skip the bookkeeping entirely if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { @@ -340,9 +348,12 @@ private static bool UpdatePlayfieldGrab(int itemId, ref MagnetState magnet, ref var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); + var distanceSq = magnet.MagnetType == MagnetType.Spatial + ? math.lengthsq(ball.Position - Center3D(in magnet)) + : math.lengthsq(ball.Position.xy - magnet.Position); var isInGrabRange = magnet.GrabRadius > 0f && magnet.Strength > 0f && - math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.GrabRadius * magnet.GrabRadius; + distanceSq <= magnet.GrabRadius * magnet.GrabRadius; if (!isInGrabRange) { magnet.ReleasedBalls.SetBits(bitIndex, false); @@ -360,52 +371,24 @@ private static bool UpdatePlayfieldGrab(int itemId, ref MagnetState magnet, ref magnet.GrabbedBalls.SetBits(bitIndex, true); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); } - switch (magnet.Profile) { - case MagnetForceProfile.Physical: - ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity); + switch (magnet.MagnetType) { + case MagnetType.Spatial: + ApplySpatialPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity); break; default: - ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity); + switch (magnet.Profile) { + case MagnetForceProfile.Physical: + ApplyPhysicalHold(ref ball, in magnet, physicsDiffTime, magnetVelocity.xy); + break; + default: + ApplyVpxCompatibleGrab(ref ball, in magnet, magnetVelocity.xy); + break; + } break; } return true; } - private static bool UpdateSpatialGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float3 magnetVelocity) - { - if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { - return false; - } - - var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); - var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); - if (magnet.GrabRadius <= 0f || magnet.Strength <= 0f) { - magnet.ReleasedBalls.SetBits(bitIndex, false); - if (isGrabbed) { - ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ball.Id, ref state, false, magnetVelocity); - } - return false; - } - - var center = Center3D(in magnet); - var isInGrabRange = isGrabbed || math.lengthsq(ball.Position - center) <= magnet.GrabRadius * magnet.GrabRadius; - if (!isInGrabRange) { - magnet.ReleasedBalls.SetBits(bitIndex, false); - return false; - } - - if (magnet.ReleasedBalls.IsSet(bitIndex)) { - return false; - } - - if (!isGrabbed) { - magnet.GrabbedBalls.SetBits(bitIndex, true); - state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); - } - ApplySpatialGrab(ref ball, in magnet, magnetVelocity); - return true; - } - private static float PhysicalCoreRadius(in MagnetState magnet) { return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); @@ -424,7 +407,7 @@ private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref P ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, false); } - private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab, float3 carrierVelocity = default) + private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { return; @@ -433,22 +416,9 @@ private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int b if (suppressRegrab) { magnet.ReleasedBalls.SetBits(bitIndex, true); } - ReleaseSpatialBallIfNeeded(ref magnet, ref state, ballId, carrierVelocity); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } - private static void ReleaseSpatialBallIfNeeded(ref MagnetState magnet, ref PhysicsState state, int ballId, float3 carrierVelocity) - { - if (magnet.MagnetType != MagnetType.Spatial || !state.Balls.ContainsKey(ballId)) { - return; - } - ref var ball = ref state.Balls.GetValueByRef(ballId); - ball.IsFrozen = false; - ball.Velocity = carrierVelocity; - ball.OldVelocity = carrierVelocity; - ball.AngularMomentum = float3.zero; - } - private static void ClearReleasedBall(ref MagnetState magnet, ref PhysicsState state, int ballId) { if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { From 0e6e9211ffd3fada5c7df0fed02db2a0e72fd0d8 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 16:38:18 +0200 Subject: [PATCH 39/50] coils: carry a normalized strength value through the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a float value to CoilEventArgs and IApiCoil.OnCoil(float), normalized to [0..1] — 0/1 for plain on/off coils, or the duty-cycle strength for PWM-integrated (modulated) coils. DeviceCoil routes it through an optional OnValue action; CoilPlayer dispatches the value; on/off consumers are unchanged (value > 0). The magnet consumes it to scale its strength by the duty cycle, so a ROM that PWMs a magnet coil (e.g. Iron Man) drives a proportional pull instead of full-or-nothing. --- .../VisualPinball.Unity/Game/CoilPlayer.cs | 224 +++++++++--------- .../VisualPinball.Unity/Game/DeviceCoil.cs | 18 +- .../Game/Engine/IGamelogicEngine.cs | 16 ++ .../VPT/Bumper/BumperApi.cs | 135 +++++------ .../VisualPinball.Unity/VPT/IApi.cs | 9 + .../VPT/Magnet/MagnetApi.cs | 22 +- 6 files changed, 244 insertions(+), 180 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 92249149f..4ff2c4fa9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -16,11 +16,11 @@ #nullable enable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using NLog; using UnityEngine; using Logger = NLog.Logger; @@ -101,13 +101,13 @@ public void OnStart() } } - // Ensure all dictionary writes above are visible to the simulation - // thread before it can call HandleCoilEventSimulationThread(). - Thread.MemoryBarrier(); - - if (_coilAssignments.Count > 0) { - _gamelogicEngine.OnCoilChanged += HandleCoilEvent; - } + // Ensure all dictionary writes above are visible to the simulation + // thread before it can call HandleCoilEventSimulationThread(). + Thread.MemoryBarrier(); + + if (_coilAssignments.Count > 0) { + _gamelogicEngine.OnCoilChanged += HandleCoilEvent; + } } } @@ -117,21 +117,21 @@ public void OnStart() /// /// Mapping to assign /// If it's a flasher - private void AssignCoilMapping(CoilMapping coilMapping, bool isLampCoil) - { - AssignCoilMapping(coilMapping.Id, coilMapping, isLampCoil); - if (int.TryParse(coilMapping.Id, out var id) && id.ToString() != coilMapping.Id) { - AssignCoilMapping(id.ToString(), coilMapping, isLampCoil); - } - - if (!isLampCoil && coilMapping.Device != null && coilMapping.DeviceItem == PlungerComponent.FireAndPullBackCoilId) { - // This mode's inactive state is actively pulled back, matching VPX scripts that call PullBack at table init. - _coilDevices[coilMapping.Device].Coil(coilMapping.DeviceItem)?.OnCoil(false); - } - } - - private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil) - { + private void AssignCoilMapping(CoilMapping coilMapping, bool isLampCoil) + { + AssignCoilMapping(coilMapping.Id, coilMapping, isLampCoil); + if (int.TryParse(coilMapping.Id, out var id) && id.ToString() != coilMapping.Id) { + AssignCoilMapping(id.ToString(), coilMapping, isLampCoil); + } + + if (!isLampCoil && coilMapping.Device != null && coilMapping.DeviceItem == PlungerComponent.FireAndPullBackCoilId) { + // This mode's inactive state is actively pulled back, matching VPX scripts that call PullBack at table init. + _coilDevices[coilMapping.Device].Coil(coilMapping.DeviceItem)?.OnCoil(false); + } + } + + private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCoil) + { if (!_coilAssignments.ContainsKey(id)) { _coilAssignments[id] = new List(); } @@ -140,19 +140,19 @@ private void AssignCoilMapping(string id, CoilMapping coilMapping, bool isLampCo w.DestinationDeviceItem == coilMapping.DeviceItem && w.IsDynamic) != null; - _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire)); - CoilStatuses[id] = false; - } - - private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) - { - // always save state - CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled; + _coilAssignments[id].Add(new CoilDestConfig(coilMapping.Device, coilMapping.DeviceItem, isLampCoil, hasDynamicWire)); + CoilStatuses[id] = false; + } + + private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) + { + // always save state + CoilStatuses[coilEvent.Id] = coilEvent.IsEnabled; // trigger coil if mapped - if (_coilAssignments.ContainsKey(coilEvent.Id)) { - foreach (var destConfig in _coilAssignments[coilEvent.Id]) { - if (destConfig.HasDynamicWire) { + if (_coilAssignments.ContainsKey(coilEvent.Id)) { + foreach (var destConfig in _coilAssignments[coilEvent.Id]) { + if (destConfig.HasDynamicWire) { // goes back through the wire mapping, which will decide whether it has already sent the event or not _wirePlayer!.HandleCoilEvent(coilEvent.Id, coilEvent.IsEnabled); continue; @@ -179,7 +179,9 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) continue; } - coil.OnCoil(coilEvent.IsEnabled); + // pass the normalized strength; on/off coils get 0/1, PWM-integrated + // coils (e.g. magnets) get the duty-cycle value + coil.OnCoil(coilEvent.Value); } else { Logger.Error($"Cannot trigger unknown coil item \"{destConfig}\" for {coilEvent.Id}."); @@ -193,78 +195,78 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) } else { Logger.Info($"Ignoring unassigned coil \"{coilEvent.Id}\"."); } - } - - internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) - { - if (!_coilAssignments.ContainsKey(id)) { - return false; - } - - var dispatched = false; - foreach (var destConfig in _coilAssignments[id]) { - if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { - continue; - } - - if (!_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is ISimulationThreadCoil simulationThreadCoil) { - simulationThreadCoil.OnCoilSimulationThread(isEnabled); - dispatched = true; - } - } - - return dispatched; - } - - internal bool SupportsSimulationThreadDispatch(string id) - { - if (!_coilAssignments.ContainsKey(id)) { - return false; - } - - foreach (var destConfig in _coilAssignments[id]) { - if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { - continue; - } - - if (!_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is DeviceCoil deviceCoil && deviceCoil.SupportsSimulationThreadDispatch) { - return true; - } - } - - return false; - } - - public void OnDestroy() - { - if (_coilAssignments.Count > 0 && _gamelogicEngine != null) { - _gamelogicEngine.OnCoilChanged -= HandleCoilEvent; - } - - // Reset simulation-thread state on all device coils so a - // subsequent play session starts clean. - foreach (var destList in _coilAssignments.Values) { - foreach (var destConfig in destList) { - if (destConfig.Device == null || !_coilDevices.ContainsKey(destConfig.Device)) { - continue; - } - var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); - if (coil is DeviceCoil deviceCoil) { - deviceCoil.ResetSimulationState(); - } - } - } - } + } + + internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) + { + if (!_coilAssignments.ContainsKey(id)) { + return false; + } + + var dispatched = false; + foreach (var destConfig in _coilAssignments[id]) { + if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { + continue; + } + + if (!_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is ISimulationThreadCoil simulationThreadCoil) { + simulationThreadCoil.OnCoilSimulationThread(isEnabled); + dispatched = true; + } + } + + return dispatched; + } + + internal bool SupportsSimulationThreadDispatch(string id) + { + if (!_coilAssignments.ContainsKey(id)) { + return false; + } + + foreach (var destConfig in _coilAssignments[id]) { + if (destConfig.HasDynamicWire || destConfig.IsLampCoil || destConfig.Device == null) { + continue; + } + + if (!_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is DeviceCoil deviceCoil && deviceCoil.SupportsSimulationThreadDispatch) { + return true; + } + } + + return false; + } + + public void OnDestroy() + { + if (_coilAssignments.Count > 0 && _gamelogicEngine != null) { + _gamelogicEngine.OnCoilChanged -= HandleCoilEvent; + } + + // Reset simulation-thread state on all device coils so a + // subsequent play session starts clean. + foreach (var destList in _coilAssignments.Values) { + foreach (var destConfig in destList) { + if (destConfig.Device == null || !_coilDevices.ContainsKey(destConfig.Device)) { + continue; + } + var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); + if (coil is DeviceCoil deviceCoil) { + deviceCoil.ResetSimulationState(); + } + } + } + } #if UNITY_EDITOR private void RefreshUI() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index 3c94685d4..3a916cbda 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -59,20 +59,32 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil protected Action OnEnableSimulationThread; protected Action OnDisableSimulationThread; + /// + /// Optional handler for the normalized coil strength in [0..1]. Set by coils + /// whose effect scales with the PWM duty cycle (e.g. magnets). Fired on every + /// call, after the enable/disable transition. + /// + protected Action OnValue; + private readonly Player _player; public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null, - Action onEnableSimulationThread = null, Action onDisableSimulationThread = null) + Action onEnableSimulationThread = null, Action onDisableSimulationThread = null, + Action onValue = null) { _player = player; OnEnable = onEnable; OnDisable = onDisable; OnEnableSimulationThread = onEnableSimulationThread; OnDisableSimulationThread = onDisableSimulationThread; + OnValue = onValue; } - public void OnCoil(bool enabled) + public void OnCoil(bool enabled) => OnCoil(enabled ? 1f : 0f); + + public void OnCoil(float value) { + var enabled = value > 0f; Interlocked.Exchange(ref _isEnabled, enabled ? 1 : 0); // When the simulation thread is actively handling this coil, @@ -87,6 +99,8 @@ public void OnCoil(bool enabled) OnDisable?.Invoke(); } } + // magnitude for consumers that scale with the duty cycle (e.g. magnets) + OnValue?.Invoke(value); CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); #if UNITY_EDITOR RefreshUI(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs index 6234d517f..ab4a74f3f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Engine/IGamelogicEngine.cs @@ -367,10 +367,26 @@ public readonly struct CoilEventArgs /// public readonly bool IsEnabled; + /// + /// Normalized coil strength in [0..1]. For plain on/off coils this is 0 or 1. + /// For PWM-integrated (modulated) coils — e.g. the magnet coils of WPC/SAM + /// ROMs — it is the duty-cycle strength. is + /// Value > 0. + /// + public readonly float Value; + public CoilEventArgs(string id, bool isEnabled) { Id = id; IsEnabled = isEnabled; + Value = isEnabled ? 1f : 0f; + } + + public CoilEventArgs(string id, float value) + { + Id = id; + Value = value; + IsEnabled = value > 0f; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs index 843272072..56437ee15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperApi.cs @@ -16,10 +16,10 @@ using System; using System.Linq; -using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.VPT.Bumper; -using VisualPinball.Unity.Collections; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { @@ -68,47 +68,50 @@ public BumperApi(GameObject go, Player player, PhysicsEngine physicsEngine) : ba void IApiSwitch.AddWireDest(WireDestConfig wireConfig) => AddWireDest(wireConfig); void IApiSwitch.RemoveWireDest(string destId) => RemoveWireDest(destId); - void IApiCoil.OnCoil(bool enabled) - { - if (enabled) { - var bumperPos = (float3)MainComponent.Position; - var force = ColliderComponent.Force; - var switchColliderId = _switchColliderId; - var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.RingAnimation.IsHit = true; - - var idsOfBallsInColl = state.InsideOfs.GetIdsOfBallsInsideItem(ItemId); - foreach (var ballId in idsOfBallsInColl) { - if (!state.Balls.ContainsKey(ballId)) { - continue; - } - - ref var ballState = ref state.Balls.GetValueByRef(ballId); - var bumpDirection = ballState.Position - bumperPos; - bumpDirection.z = 0f; - bumpDirection = math.normalize(bumpDirection); - var collEvent = new CollisionEventData { - HitTime = 0f, - HitNormal = bumpDirection, - HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * force, - HitDistance = 0f, - HitFlag = false, - HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), - IsContact = true, - ColliderId = switchColliderId, - IsKinematic = false, - BallId = ballId - }; - BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); - } - }); - } - - CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); + void IApiCoil.OnCoil(bool enabled) + { + if (enabled) { + var bumperPos = (float3)MainComponent.Position; + var force = ColliderComponent.Force; + var switchColliderId = _switchColliderId; + var physicsMaterialData = ColliderComponent.GetPhysicsMaterialData(); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.RingAnimation.IsHit = true; + + var idsOfBallsInColl = state.InsideOfs.GetIdsOfBallsInsideItem(ItemId); + foreach (var ballId in idsOfBallsInColl) { + if (!state.Balls.ContainsKey(ballId)) { + continue; + } + + ref var ballState = ref state.Balls.GetValueByRef(ballId); + var bumpDirection = ballState.Position - bumperPos; + bumpDirection.z = 0f; + bumpDirection = math.normalize(bumpDirection); + var collEvent = new CollisionEventData { + HitTime = 0f, + HitNormal = bumpDirection, + HitVelocity = new float2(bumpDirection.x, bumpDirection.y) * force, + HitDistance = 0f, + HitFlag = false, + HitOrgNormalVelocity = math.dot(bumpDirection, math.normalize(ballState.Velocity)), + IsContact = true, + ColliderId = switchColliderId, + IsKinematic = false, + BallId = ballId + }; + BumperCollider.PushBallAway(ref ballState, in bumperState.Static, ref collEvent, in physicsMaterialData, ref state); + } + }); + } + + CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); } + // a bumper fires on energize; the PWM magnitude is not meaningful for it + void IApiCoil.OnCoil(float value) => (this as IApiCoil).OnCoil(value > 0f); + void IApiWireDest.OnChange(bool enabled) => (this as IApiCoil).OnCoil(enabled); internal override void AddWireDest(WireDestConfig wireConfig) @@ -123,16 +126,16 @@ internal override void RemoveWireDest(string destId) UpdateBumperWireState(); } - private void UpdateBumperWireState() - { - string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; - BumperComponent bumperComponent = MainComponent; - var isSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.IsSwitchWiredToCoil = isSwitchWiredToCoil; - }); - } + private void UpdateBumperWireState() + { + string coilId = MainComponent.AvailableCoils.FirstOrDefault().Id; + BumperComponent bumperComponent = MainComponent; + var isSwitchWiredToCoil = HasWireDest(bumperComponent, coilId); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.IsSwitchWiredToCoil = isSwitchWiredToCoil; + }); + } #endregion @@ -174,19 +177,19 @@ void IApiHittable.OnHit(int ballId, bool isUnHit) Switch?.Invoke(this, new SwitchEventArgs(false, ballId)); OnSwitch(false); } - } else { - Hit?.Invoke(this, new HitEventArgs(ballId)); - PhysicsEngine.MutateState((ref PhysicsState state) => { - ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); - bumperState.SkirtAnimation.HitEvent = true; - bumperState.RingAnimation.IsHit = true; - if (state.Balls.ContainsKey(ballId)) { - bumperState.SkirtAnimation.BallPosition = state.Balls.GetValueByRef(ballId).Position; - } - }); - Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); - OnSwitch(true); - } + } else { + Hit?.Invoke(this, new HitEventArgs(ballId)); + PhysicsEngine.MutateState((ref PhysicsState state) => { + ref var bumperState = ref state.BumperStates.GetValueByRef(ItemId); + bumperState.SkirtAnimation.HitEvent = true; + bumperState.RingAnimation.IsHit = true; + if (state.Balls.ContainsKey(ballId)) { + bumperState.SkirtAnimation.BallPosition = state.Balls.GetValueByRef(ballId).Position; + } + }); + Switch?.Invoke(this, new SwitchEventArgs(true, ballId)); + OnSwitch(true); + } } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs index 171390285..5cbaf0d6d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/IApi.cs @@ -222,6 +222,15 @@ public interface IApiCoil : IApiWireDest /// /// Whether the coil was enabled (power) or disabled (no power). void OnCoil(bool enabled); + + /// + /// The coil strength changed. is normalized to [0..1]: + /// 0 or 1 for plain on/off coils, or the duty-cycle strength for PWM-integrated + /// (modulated) coils. Consumers that only care about on/off can forward this to + /// as value > 0. + /// + void OnCoil(float value); + event EventHandler CoilStatusChanged; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 7d5b9e849..21b5fae44 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -48,7 +48,7 @@ internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) _itemId = UnityObjectId.Get(_component.gameObject); _isEnabled = _component.IsEnabledOnStart; - _magnetCoil = new DeviceCoil(_player, OnCoilEnabled, OnCoilDisabled, OnCoilEnabledSimThread, OnCoilDisabledSimThread); + _magnetCoil = new DeviceCoil(_player, OnCoilEnabled, OnCoilDisabled, OnCoilEnabledSimThread, OnCoilDisabledSimThread, OnCoilValue); _ballHeldSwitch = new DeviceSwitch(MagnetComponent.BallHeldSwitchItem, false, SwitchDefault.NormallyOpen, _player, _physicsEngine); } @@ -154,6 +154,26 @@ private IApiSwitch Switch(string deviceItem) private void OnCoilEnabledSimThread() => SetEnabledSimThread(true); private void OnCoilDisabledSimThread() => SetEnabledSimThread(false); + /// + /// Scales the magnet strength by a PWM-integrated coil duty cycle in [0..1] + /// (e.g. Iron Man's ROM pulses the magnet coils). On/off is handled by the + /// enable/disable callbacks; this only modulates the strength from the authored + /// value. A plain on/off coil sends 1, leaving the authored strength unchanged. + /// + private void OnCoilValue(float value) + { + if (!_physicsEngine) { + return; + } + var strength = _component.Strength * math.saturate(value); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.Strength = strength; + } + }); + } + private void SetEnabled(bool enabled) { _isEnabled = enabled; From e936578c95f84cd40c251e49ca41af888a90a170 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 6 Jul 2026 16:41:47 +0200 Subject: [PATCH 40/50] doc(magnets): document ROM-modulated (PWM) magnet strength --- .../Documentation~/creators-guide/manual/mechanisms/magnets.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index 818220724..cb683043c 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -45,6 +45,8 @@ Magnets also expose one switch: Map ROM solenoids to `magnet_coil` in the [Coil Manager](../../editor/coil-manager.md). The `ball_held` switch can be used by game logic when a table needs explicit confirmation that a ball is held. +Some ROMs pulse-width-modulate (PWM) their magnet coils to vary grip — Iron Man, for example, drives its magnets at partial strength. When the gamelogic engine reports a modulated coil (PinMAME does this for WPC/SAM ROMs), the magnet scales its authored **Strength** by the duty cycle, so a coil held at 50% produces half the pull rather than full-or-nothing. Plain on/off coils leave the authored strength unchanged. The scaling multiplies the authored value, so set **Strength** to the full-power grip you want at 100% duty. + ## Force Profiles ### VPX Compatible From 73da456e72fdfd4c8aa9a9ac7cfa3e754305c5fd Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:19:46 +0200 Subject: [PATCH 41/50] physics(magnets): keep grabbed balls in the hold path Treat a ball already owned by the magnet as affected even when a moving field carries its center outside the coarse radius or height window. This preserves ownership until the profile-specific hold decides that the ball has actually broken free. Restore the focused grab-bookkeeping lookup used by that gate. The existing GrabbedBallSurvivesMovingHeightWindow regression test now passes while spatial knock-loose release continues through UpdateGrab. --- .../VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 05183cdcb..39138e0b2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -63,7 +63,8 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState continue; } - if (!IsBallInRange(in ball, in magnet)) { + var affectsBall = IsBallInRange(in ball, in magnet) || IsGrabbedBall(in magnet, ref state, ball.Id); + if (!affectsBall) { ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); ClearReleasedBall(ref magnet, ref state, ball.Id); UpdateMembership(itemId, ball.Id, false, ref state); @@ -333,6 +334,16 @@ internal static bool IsBallInRange(in BallState ball, in MagnetState magnet) return math.lengthsq(ball.Position.xy - magnet.Position) <= magnet.Radius * magnet.Radius; } + /// + /// A held ball stays owned by the magnet even when a moving field carries its + /// center outside the coarse influence volume. The profile-specific hold logic + /// is responsible for deciding whether the ball has actually broken free. + /// + private static bool IsGrabbedBall(in MagnetState magnet, ref PhysicsState state, int ballId) + => magnet.GrabbedBalls.Value != 0UL + && state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex) + && magnet.GrabbedBalls.IsSet(bitIndex); + /// /// Shared grab bookkeeping for both magnet kinds. The only differences are the /// distance metric (spherical for spatial, planar for playfield) and which hold From 9b1755e5c683a9cbaf8132859888f8f9a61c9b7a Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:23:06 +0200 Subject: [PATCH 42/50] coils: carry strength through simulation dispatch Replace the simulation-thread boolean coil contract with the normalized float already present in CoilEventArgs. DeviceCoil now delivers every PWM value, including changes that leave the boolean enabled state unchanged, while existing transition callbacks keep their previous semantics. Give physics-sensitive consumers a dedicated simulation-thread value callback and suppress the later main-thread value callback once that fast path is active. The magnet applies enabled state and scaled strength together, avoiding a one-frame full-power command, and a focused test covers repeated values within one enabled interval. --- .../Physics/MagnetPhysicsTests.cs | 21 +++++++++++ .../VisualPinball.Unity/Game/CoilPlayer.cs | 4 +- .../VisualPinball.Unity/Game/DeviceCoil.cs | 37 +++++++++++-------- .../VisualPinball.Unity/Game/Player.cs | 8 ++-- .../Simulation/SimulationThread.cs | 6 +-- .../Simulation/SimulationThreadComponent.cs | 2 +- .../VPT/Magnet/MagnetApi.cs | 36 +++++++++--------- 7 files changed, 72 insertions(+), 42 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 37e1e486d..e3b20053e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -561,6 +561,27 @@ public void SpatialCoilOffReleasesHeldBall() Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f)), "a released ball keeps its live velocity"); } + [Test] + public void SimulationThreadCoilDeliversEveryPwmValue() + { + var enableCount = 0; + var valueCount = 0; + var lastValue = 0f; + var coil = new DeviceCoil(null, + onEnableSimulationThread: () => enableCount++, + onValueSimulationThread: value => { + valueCount++; + lastValue = value; + }); + + coil.OnCoilSimulationThread(0.25f); + coil.OnCoilSimulationThread(0.75f); + + Assert.That(enableCount, Is.EqualTo(1), "both values keep the coil enabled"); + Assert.That(valueCount, Is.EqualTo(2), "PWM changes must not be collapsed into a bool"); + Assert.That(lastValue, Is.EqualTo(0.75f)); + } + private static BallState CreateBall() { return new BallState { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 4ff2c4fa9..408e646a9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -197,7 +197,7 @@ private void HandleCoilEvent(object sender, CoilEventArgs coilEvent) } } - internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) + internal bool HandleCoilEventSimulationThread(string id, float value) { if (!_coilAssignments.ContainsKey(id)) { return false; @@ -215,7 +215,7 @@ internal bool HandleCoilEventSimulationThread(string id, bool isEnabled) var coil = _coilDevices[destConfig.Device].Coil(destConfig.DeviceItem); if (coil is ISimulationThreadCoil simulationThreadCoil) { - simulationThreadCoil.OnCoilSimulationThread(isEnabled); + simulationThreadCoil.OnCoilSimulationThread(value); dispatched = true; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index 3a916cbda..ca0e7f83b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -22,7 +22,7 @@ namespace VisualPinball.Unity { public interface ISimulationThreadCoil { - void OnCoilSimulationThread(bool enabled); + void OnCoilSimulationThread(float value); } public class DeviceCoil : IApiCoil, ISimulationThreadCoil @@ -49,7 +49,9 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil /// that still touch Unity objects on enable/disable, should stay on the /// normal main-thread path. /// - public bool SupportsSimulationThreadDispatch => OnEnableSimulationThread != null || OnDisableSimulationThread != null; + public bool SupportsSimulationThreadDispatch => OnEnableSimulationThread != null + || OnDisableSimulationThread != null + || OnValueSimulationThread != null; public bool IsEnabled => Volatile.Read(ref _isEnabled) != 0; public event EventHandler CoilStatusChanged; @@ -58,6 +60,7 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil protected Action OnDisable; protected Action OnEnableSimulationThread; protected Action OnDisableSimulationThread; + protected Action OnValueSimulationThread; /// /// Optional handler for the normalized coil strength in [0..1]. Set by coils @@ -70,7 +73,7 @@ public class DeviceCoil : IApiCoil, ISimulationThreadCoil public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null, Action onEnableSimulationThread = null, Action onDisableSimulationThread = null, - Action onValue = null) + Action onValue = null, Action onValueSimulationThread = null) { _player = player; OnEnable = onEnable; @@ -78,6 +81,7 @@ public DeviceCoil(Player player, Action onEnable = null, Action onDisable = null OnEnableSimulationThread = onEnableSimulationThread; OnDisableSimulationThread = onDisableSimulationThread; OnValue = onValue; + OnValueSimulationThread = onValueSimulationThread; } public void OnCoil(bool enabled) => OnCoil(enabled ? 1f : 0f); @@ -90,40 +94,43 @@ public void OnCoil(float value) // When the simulation thread is actively handling this coil, // skip the main-thread enable/disable callbacks. The sim thread // already set the solenoid flag directly via OnCoilSimulationThread. - // We still update _isEnabled above and fire CoilStatusChanged/UI - // below, since those are main-thread-only concerns. + // We still update _isEnabled above and fire CoilStatusChanged/UI below, + // since those are main-thread-only concerns. The value callback is also + // suppressed because the simulation thread already consumed it. if (!_simThreadActive) { if (enabled) { OnEnable?.Invoke(); } else { OnDisable?.Invoke(); } + OnValue?.Invoke(value); } - // magnitude for consumers that scale with the duty cycle (e.g. magnets) - OnValue?.Invoke(value); CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); #if UNITY_EDITOR RefreshUI(); #endif } - public void OnCoilSimulationThread(bool enabled) + public void OnCoilSimulationThread(float value) { + var enabled = value > 0f; // Mark this coil as sim-thread-active on first dispatch. // This suppresses the main-thread OnEnable/OnDisable callbacks. if (!_simThreadActive) { _simThreadActive = true; } - if (Interlocked.Exchange(ref _simulationEnabled, enabled ? 1 : 0) == (enabled ? 1 : 0)) { - return; + if (Interlocked.Exchange(ref _simulationEnabled, enabled ? 1 : 0) != (enabled ? 1 : 0)) { + if (enabled) { + OnEnableSimulationThread?.Invoke(); + } else { + OnDisableSimulationThread?.Invoke(); + } } - if (enabled) { - OnEnableSimulationThread?.Invoke(); - } else { - OnDisableSimulationThread?.Invoke(); - } + // Unlike enable/disable callbacks, analog/PWM values must be delivered + // even when the boolean state did not change. + OnValueSimulationThread?.Invoke(value); } public void OnChange(bool enabled) => OnCoil(enabled); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index d38613c2f..5f9446464 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -183,10 +183,10 @@ internal void DispatchInputAction(string actionName, bool isPressed) CabinetInputActionChanged?.Invoke(actionName, isPressed); } - internal bool DispatchCoilSimulationThread(string coilId, bool isEnabled) - { - return _coilPlayer.HandleCoilEventSimulationThread(coilId, isEnabled); - } + internal bool DispatchCoilSimulationThread(string coilId, float value) + { + return _coilPlayer.HandleCoilEventSimulationThread(coilId, value); + } public bool SupportsSimulationThreadCoilDispatch(string coilId) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 0fd4bc8d1..077849294 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -50,7 +50,7 @@ public class SimulationThread : IDisposable private readonly IGamelogicPerformanceStats _gamelogicPerformanceStats; private readonly IGamelogicLatencyStats _gamelogicLatencyStats; private readonly IGamelogicInputDispatcher _inputDispatcher; - private readonly Action _simulationCoilDispatcher; + private readonly Action _simulationCoilDispatcher; private readonly InputEventBuffer _inputBuffer; private readonly SimulationState _sharedState; private readonly System.Random _nudgeRandom = new System.Random(); @@ -115,7 +115,7 @@ public PendingSwitchEvent(string switchId, bool isClosed) #region Constructor public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicEngine, - Action simulationCoilDispatcher) + Action simulationCoilDispatcher) { _physicsEngine = physicsEngine ?? throw new ArgumentNullException(nameof(physicsEngine)); _gamelogicEngine = gamelogicEngine; @@ -568,7 +568,7 @@ private void ProcessGamelogicOutputs() var processed = 0; while (processed < MaxCoilOutputsPerTick && _coilOutputFeed.TryDequeueCoilEvent(out var coilEvent)) { _lastCoilDispatchUsec = GetTimestampUsec(); - _simulationCoilDispatcher(coilEvent.Id, coilEvent.IsEnabled); + _simulationCoilDispatcher(coilEvent.Id, coilEvent.Value); processed++; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 7cdde2bb0..f92326a46 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -377,7 +377,7 @@ public void StartSimulation() // Create simulation thread _simulationThread = new SimulationThread(_physicsEngine, _gamelogicEngine, player != null - ? new Action((coilId, isEnabled) => player.DispatchCoilSimulationThread(coilId, isEnabled)) + ? new Action((coilId, value) => player.DispatchCoilSimulationThread(coilId, value)) : null); ConfigureTiltBobRouting(); _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 21b5fae44..83198001d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -48,7 +48,7 @@ internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) _itemId = UnityObjectId.Get(_component.gameObject); _isEnabled = _component.IsEnabledOnStart; - _magnetCoil = new DeviceCoil(_player, OnCoilEnabled, OnCoilDisabled, OnCoilEnabledSimThread, OnCoilDisabledSimThread, OnCoilValue); + _magnetCoil = new DeviceCoil(_player, onValue: OnCoilValue, onValueSimulationThread: OnCoilValueSimulationThread); _ballHeldSwitch = new DeviceSwitch(MagnetComponent.BallHeldSwitchItem, false, SwitchDefault.NormallyOpen, _player, _physicsEngine); } @@ -149,11 +149,6 @@ private IApiSwitch Switch(string deviceItem) }; } - private void OnCoilEnabled() => SetEnabled(true); - private void OnCoilDisabled() => SetEnabled(false); - private void OnCoilEnabledSimThread() => SetEnabledSimThread(true); - private void OnCoilDisabledSimThread() => SetEnabledSimThread(false); - /// /// Scales the magnet strength by a PWM-integrated coil duty cycle in [0..1] /// (e.g. Iron Man's ROM pulses the magnet coils). On/off is handled by the @@ -165,37 +160,44 @@ private void OnCoilValue(float value) if (!_physicsEngine) { return; } - var strength = _component.Strength * math.saturate(value); + var normalizedValue = math.saturate(value); + var enabled = normalizedValue > 0f; + var strength = _component.Strength * normalizedValue; + _isEnabled = enabled; _physicsEngine.MutateState((ref PhysicsState state) => { if (state.MagnetStates.ContainsKey(_itemId)) { ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.IsEnabled = enabled; magnet.Strength = strength; } }); } - private void SetEnabled(bool enabled) + private void OnCoilValueSimulationThread(float value) { + var normalizedValue = math.saturate(value); + var enabled = normalizedValue > 0f; _isEnabled = enabled; if (!_physicsEngine) { return; } - _physicsEngine.MutateState((ref PhysicsState state) => { - if (state.MagnetStates.ContainsKey(_itemId)) { - ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); - magnet.IsEnabled = enabled; - } - }); + ref var magnet = ref _physicsEngine.MagnetState(_itemId); + magnet.IsEnabled = enabled; + magnet.Strength = _component.Strength * normalizedValue; } - private void SetEnabledSimThread(bool enabled) + private void SetEnabled(bool enabled) { _isEnabled = enabled; if (!_physicsEngine) { return; } - ref var magnet = ref _physicsEngine.MagnetState(_itemId); - magnet.IsEnabled = enabled; + _physicsEngine.MutateState((ref PhysicsState state) => { + if (state.MagnetStates.ContainsKey(_itemId)) { + ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); + magnet.IsEnabled = enabled; + } + }); } void IApiMagnetEvents.OnMagnetBallEntered(int ballId) => BallEntered?.Invoke(this, new HitEventArgs(ballId)); From 38f8ad9dd6662927780c01ef633fb2bd01df885d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:27:58 +0200 Subject: [PATCH 43/50] physics(magnets): integrate coil rise and decay Separate the normalized electrical command, effective coil current, and applied force in MagnetState. Physical and Spatial magnets now advance current with authorable rise and fall time constants, using a stable implicit RL step and current-squared force instead of switching instantly at the next physics tick. Keep VPX Compatible response immediate, preserve runtime electrical state across inspector synchronization, serialize the new tuning fields, and expose them in the inspector and documentation. Focused tests cover one-time-constant rise, flyback decay, and unchanged VPX response. --- .../Assets/Resources/Prefabs/Magnet.prefab | 4 ++ .../manual/mechanisms/magnets.md | 8 ++- .../VPT/Magnet/MagnetInspector.cs | 9 +++ .../Physics/MagnetPhysicsTests.cs | 61 ++++++++++++++++++- .../VPT/Magnet/MagnetApi.cs | 13 ++-- .../VPT/Magnet/MagnetComponent.cs | 24 ++++++++ .../VPT/Magnet/MagnetPackable.cs | 6 ++ .../VPT/Magnet/MagnetPhysics.cs | 53 +++++++++++++--- .../VPT/Magnet/MagnetState.cs | 5 ++ 9 files changed, 164 insertions(+), 19 deletions(-) diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab index f76d0175c..90556e17d 100644 --- a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab @@ -46,9 +46,13 @@ MonoBehaviour: m_EditorClassIdentifier: VisualPinball.Unity::VisualPinball.Unity.MagnetComponent Radius: 50 Strength: 10 + MagnetType: 0 ForceProfile: 0 + CoilRiseTime: 20 + CoilFallTime: 20 GrabBall: 0 GrabRadius: 10.8 HeightRange: 50 IsEnabledOnStart: 0 + IsKinematic: 0 DrawDebugForces: 0 diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index cb683043c..8f10a5eca 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -21,8 +21,10 @@ The selected object shows a radius gizmo in the scene view. Playfield magnets dr | **Magnet Type** | **Playfield** for under-playfield magnets with a cylindrical range, **Spatial** for mech-mounted magnets that grab and carry balls in 3-D. | | **Radius** | Influence radius in millimeters. Playfield magnets use a planar radius; spatial magnets use a spherical radius. | | **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. Spatial magnets ignore this field. | -| **Strength** | Magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values. Negative values repel. | +| **Strength** | Full-power magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values and negative values repel. | | **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. Spatial magnets always use physical 3-D force semantics. | +| **Coil Rise Time** | Electrical rise time constant in milliseconds for Physical and Spatial magnets. Current reaches about 63% after one time constant. | +| **Coil Fall Time** | Electrical decay time constant in milliseconds for Physical and Spatial magnets. Tune this to the coil driver and flyback circuit. | | **Grab Ball** | Enables center hold behavior inside the grab radius. | | **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible playfield mode clamps to center; Physical playfield mode uses a spring-damper hold; Spatial mode uses a 3-D spring-damper hold that a hard hit can overcome. | | **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | @@ -45,7 +47,7 @@ Magnets also expose one switch: Map ROM solenoids to `magnet_coil` in the [Coil Manager](../../editor/coil-manager.md). The `ball_held` switch can be used by game logic when a table needs explicit confirmation that a ball is held. -Some ROMs pulse-width-modulate (PWM) their magnet coils to vary grip — Iron Man, for example, drives its magnets at partial strength. When the gamelogic engine reports a modulated coil (PinMAME does this for WPC/SAM ROMs), the magnet scales its authored **Strength** by the duty cycle, so a coil held at 50% produces half the pull rather than full-or-nothing. Plain on/off coils leave the authored strength unchanged. The scaling multiplies the authored value, so set **Strength** to the full-power grip you want at 100% duty. +Some ROMs pulse-width-modulate (PWM) their magnet coils to vary grip — Iron Man, for example, drives its magnets at partial strength. The normalized coil command travels through the simulation-thread path without being reduced to a boolean. VPX Compatible scales its authored **Strength** directly by that command. Physical and Spatial magnets instead integrate effective coil current using **Coil Rise Time** and **Coil Fall Time**, then derive force from current squared. Set **Strength** to the full-power grip at 100% current. ## Force Profiles @@ -57,7 +59,7 @@ This mode is the default. It is the right choice for ROM-controlled magnets and ### Physical -Use **Physical** for new VPE-authored tables. It uses a saturated inverse-square force so the field gets stronger near the core without exploding at zero distance. Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. +Use **Physical** for new VPE-authored tables. Its electrical response ramps toward the commanded current instead of switching the field instantaneously, and the field decays after power is removed. Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index cee21dfcb..c70143b1b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -26,6 +26,8 @@ public class MagnetInspector : ItemInspector private SerializedProperty _strengthProperty; private SerializedProperty _magnetTypeProperty; private SerializedProperty _forceProfileProperty; + private SerializedProperty _coilRiseTimeProperty; + private SerializedProperty _coilFallTimeProperty; private SerializedProperty _grabBallProperty; private SerializedProperty _grabRadiusProperty; private SerializedProperty _heightRangeProperty; @@ -43,6 +45,8 @@ protected override void OnEnable() _strengthProperty = serializedObject.FindProperty(nameof(MagnetComponent.Strength)); _magnetTypeProperty = serializedObject.FindProperty(nameof(MagnetComponent.MagnetType)); _forceProfileProperty = serializedObject.FindProperty(nameof(MagnetComponent.ForceProfile)); + _coilRiseTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilRiseTime)); + _coilFallTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilFallTime)); _grabBallProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabBall)); _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); _heightRangeProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeightRange)); @@ -69,6 +73,11 @@ public override void OnInspectorGUI() if (!isSpatial) { PropertyField(_forceProfileProperty); } + var usesPhysicalResponse = isSpatial || _forceProfileProperty.enumValueIndex == (int)MagnetForceProfile.Physical; + if (usesPhysicalResponse) { + PropertyField(_coilRiseTimeProperty); + PropertyField(_coilFallTimeProperty); + } EditorGUILayout.Space(8f); PropertyField(_grabBallProperty); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index e3b20053e..61d8a2191 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -38,6 +38,7 @@ public void GrabbedBallSurvivesMovingHeightWindow() Height = 0f, Radius = 100f, Strength = 20f, + CommandedPower = 1f, GrabRadius = 20f, PlanarDamping = 0.985f, HeightRange = 25f, @@ -63,6 +64,7 @@ public void VpxCompatibleForceScalesToOneMillisecondTicks() Position = float2.zero, Radius = 100f, Strength = 10f, + EffectiveStrength = 10f, PlanarDamping = 1f }; @@ -111,6 +113,7 @@ public void VpxCompatibleForceMatchesCoreVbsAttractBall() Position = float2.zero, Radius = 100f, Strength = 12f, + EffectiveStrength = 12f, PlanarDamping = 0.985f }; @@ -130,6 +133,7 @@ public void VpxCompatibleForceRepelsWithNegativeStrength() Position = float2.zero, Radius = 100f, Strength = -10f, + EffectiveStrength = -10f, PlanarDamping = 1f }; @@ -149,7 +153,8 @@ public void PhysicalForceSaturatesInsideCoreRadius() var magnet = new MagnetState { Position = float2.zero, Radius = 100f, - Strength = 400f + Strength = 400f, + EffectiveStrength = 400f }; MagnetPhysics.ApplyPhysicalForce(ref nearBall, in magnet, 1f); @@ -167,7 +172,8 @@ public void PhysicalForceRepelsWithNegativeStrength() var magnet = new MagnetState { Position = float2.zero, Radius = 100f, - Strength = -400f + Strength = -400f, + EffectiveStrength = -400f }; MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); @@ -204,6 +210,7 @@ public void SpatialPhysicalForcePullsInThreeDimensions() Height = 0f, Radius = 100f, Strength = 400f, + EffectiveStrength = 400f, MagnetType = MagnetType.Spatial }; @@ -266,6 +273,7 @@ public void PhysicalHoldPullsBallWithoutTeleporting() var magnet = new MagnetState { Position = float2.zero, Strength = 20f, + EffectiveStrength = 20f, GrabRadius = 20f }; @@ -293,6 +301,7 @@ public void SpatialGrabHoldsBallWithForceNotFreeze() Height = 12f, Radius = 100f, Strength = 20f, + CommandedPower = 1f, GrabRadius = 20f, IsEnabled = true, MagnetType = MagnetType.Spatial @@ -325,6 +334,7 @@ public void SpatialHoldTracksMovingHoldPoint() Height = 10f, Radius = 100f, Strength = 20f, + CommandedPower = 1f, GrabRadius = 20f, IsEnabled = true, MagnetType = MagnetType.Spatial @@ -364,6 +374,7 @@ public void SpatialGrabReleasesBallKnockedOutsideGrabRadius() Height = 10f, Radius = 100f, Strength = 20f, + CommandedPower = 1f, GrabRadius = 20f, IsEnabled = true, MagnetType = MagnetType.Spatial @@ -387,6 +398,7 @@ public void PhysicalHoldDampsRelativeToKinematicMagnetVelocity() var magnet = new MagnetState { Position = float2.zero, Strength = 20f, + EffectiveStrength = 20f, GrabRadius = 20f }; var magnetVelocity = new float2(7f, -2f); @@ -561,6 +573,51 @@ public void SpatialCoilOffReleasesHeldBall() Assert.That(ball.Velocity, Is.EqualTo(new float3(3f, -2f, 5f)), "a released ball keeps its live velocity"); } + [Test] + public void PhysicalCoilRampsAndDecaysWithTimeConstants() + { + var magnet = new MagnetState { + Strength = 100f, + CommandedPower = 1f, + RiseTime = 2f, + FallTime = 1f, + IsEnabled = true, + Profile = MagnetForceProfile.Physical + }; + + for (var i = 0; i < 20; i++) { + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + } + var currentAfterOneRiseConstant = magnet.EffectiveCurrent; + Assert.That(currentAfterOneRiseConstant, Is.EqualTo(0.623f).Within(0.01f)); + Assert.That(magnet.EffectiveStrength, Is.EqualTo(100f * currentAfterOneRiseConstant * currentAfterOneRiseConstant).Within(1e-4f)); + + magnet.IsEnabled = false; + for (var i = 0; i < 10; i++) { + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + } + Assert.That(magnet.EffectiveCurrent, Is.EqualTo(currentAfterOneRiseConstant * 0.386f).Within(0.01f)); + Assert.That(magnet.EffectiveStrength, Is.GreaterThan(0f), "the field decays instead of disappearing instantly"); + } + + [Test] + public void VpxCompatibleCoilResponseRemainsInstantaneous() + { + var magnet = new MagnetState { + Strength = 20f, + CommandedPower = 0.5f, + RiseTime = 10f, + FallTime = 10f, + IsEnabled = true, + Profile = MagnetForceProfile.VpxCompatible + }; + + MagnetPhysics.AdvanceCoil(ref magnet, 0.1f); + + Assert.That(magnet.EffectiveCurrent, Is.EqualTo(0.5f)); + Assert.That(magnet.EffectiveStrength, Is.EqualTo(10f)); + } + [Test] public void SimulationThreadCoilDeliversEveryPwmValue() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 83198001d..12930340e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -150,10 +150,9 @@ private IApiSwitch Switch(string deviceItem) } /// - /// Scales the magnet strength by a PWM-integrated coil duty cycle in [0..1] - /// (e.g. Iron Man's ROM pulses the magnet coils). On/off is handled by the - /// enable/disable callbacks; this only modulates the strength from the authored - /// value. A plain on/off coil sends 1, leaving the authored strength unchanged. + /// Sets the normalized electrical command in [0..1] (e.g. Iron Man's ROM + /// pulses the magnet coils). Physical profiles integrate this command into + /// effective current; VPX Compatible applies it immediately. /// private void OnCoilValue(float value) { @@ -162,13 +161,12 @@ private void OnCoilValue(float value) } var normalizedValue = math.saturate(value); var enabled = normalizedValue > 0f; - var strength = _component.Strength * normalizedValue; _isEnabled = enabled; _physicsEngine.MutateState((ref PhysicsState state) => { if (state.MagnetStates.ContainsKey(_itemId)) { ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); magnet.IsEnabled = enabled; - magnet.Strength = strength; + magnet.CommandedPower = normalizedValue; } }); } @@ -183,7 +181,7 @@ private void OnCoilValueSimulationThread(float value) } ref var magnet = ref _physicsEngine.MagnetState(_itemId); magnet.IsEnabled = enabled; - magnet.Strength = _component.Strength * normalizedValue; + magnet.CommandedPower = normalizedValue; } private void SetEnabled(bool enabled) @@ -196,6 +194,7 @@ private void SetEnabled(bool enabled) if (state.MagnetStates.ContainsKey(_itemId)) { ref var magnet = ref state.MagnetStates.GetValueByRef(_itemId); magnet.IsEnabled = enabled; + magnet.CommandedPower = enabled ? 1f : 0f; } }); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 07813ca8e..6f982aadc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -33,6 +33,8 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic public const string BallHeldSwitchItem = "ball_held"; public const float MillimetersToWorld = 0.001f; public const float DefaultPlanarDamping = 0.985f; + public const float DefaultCoilRiseTimeMs = 20f; + public const float DefaultCoilFallTimeMs = 20f; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -50,6 +52,16 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("How the authored strength value is interpreted.")] public MagnetForceProfile ForceProfile = MagnetForceProfile.VpxCompatible; + [Min(0f)] + [Unit("ms")] + [Tooltip("Electrical rise time constant for Physical and Spatial magnets. The current reaches about 63% after one time constant.")] + public float CoilRiseTime = DefaultCoilRiseTimeMs; + + [Min(0f)] + [Unit("ms")] + [Tooltip("Electrical decay time constant for Physical and Spatial magnets. Set this to match the driver flyback circuit.")] + public float CoilFallTime = DefaultCoilFallTimeMs; + [Tooltip("Whether the magnet can hold a ball at its center.")] public bool GrabBall; @@ -128,17 +140,26 @@ private void OnValidate() Radius = math.max(0f, Radius); GrabRadius = math.max(0f, GrabRadius); HeightRange = math.max(0f, HeightRange); + CoilRiseTime = math.max(0f, CoilRiseTime); + CoilFallTime = math.max(0f, CoilFallTime); SyncPhysicsState(); } internal MagnetState CreateState() { var pos = GetPlayfieldPositionVpx(transform); + var commandedPower = IsEnabledOnStart ? 1f : 0f; + var usesPhysicalResponse = MagnetType == MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical; return new MagnetState { Position = pos.xy, Height = pos.z, Radius = MillimetersToVpx(Radius), Strength = Strength, + CommandedPower = commandedPower, + EffectiveCurrent = usesPhysicalResponse ? 0f : commandedPower, + EffectiveStrength = usesPhysicalResponse ? 0f : Strength * commandedPower, + RiseTime = CoilRiseTime / MagnetPhysics.VpxMagnetUpdateMs, + FallTime = CoilFallTime / MagnetPhysics.VpxMagnetUpdateMs, GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), IsEnabled = IsEnabledOnStart, @@ -171,6 +192,9 @@ private void SyncPhysicsState() } ref var magnet = ref state.MagnetStates.GetValueByRef(itemId); synced.IsEnabled = magnet.IsEnabled; + synced.CommandedPower = magnet.CommandedPower; + synced.EffectiveCurrent = magnet.EffectiveCurrent; + synced.EffectiveStrength = magnet.EffectiveStrength; synced.GrabbedBalls = magnet.GrabbedBalls; synced.ReleasedBalls = magnet.ReleasedBalls; magnet = synced; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index ca8693ec5..f5e649c0b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -22,6 +22,8 @@ public struct MagnetPackable public float Strength; public MagnetType MagnetType; public MagnetForceProfile ForceProfile; + public float CoilRiseTime; + public float CoilFallTime; public bool GrabBall; public float GrabRadius; public float HeightRange; @@ -36,6 +38,8 @@ public static byte[] Pack(MagnetComponent comp) Strength = comp.Strength, MagnetType = comp.MagnetType, ForceProfile = comp.ForceProfile, + CoilRiseTime = comp.CoilRiseTime, + CoilFallTime = comp.CoilFallTime, GrabBall = comp.GrabBall, GrabRadius = comp.GrabRadius, HeightRange = comp.HeightRange, @@ -52,6 +56,8 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) comp.Strength = data.Strength; comp.MagnetType = data.MagnetType; comp.ForceProfile = data.ForceProfile; + comp.CoilRiseTime = data.CoilRiseTime; + comp.CoilFallTime = data.CoilFallTime; comp.GrabBall = data.GrabBall; comp.GrabRadius = data.GrabRadius; comp.HeightRange = data.HeightRange; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 39138e0b2..913161de1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -31,11 +31,13 @@ internal static class MagnetPhysics private const float PhysicalMinimumCoreRadius = 5f; private const float PhysicalVelocityDamping = 0.02f; private const float PhysicalMinimumHoldStrength = 1f; + private const float MinEffectiveCurrent = 0.0001f; [BurstCompile] internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) { - if (!magnet.IsEnabled) { + AdvanceCoil(ref magnet, physicsDiffTime); + if (!HasActiveField(in magnet)) { ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); if (!state.InsideOfs.IsEmpty(itemId)) { ReleaseMembership(itemId, ref state); @@ -95,6 +97,35 @@ internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState } } + /// + /// Advances the normalized coil current toward its command. Physical force is + /// proportional to current squared; VPX Compatible remains instantaneous. + /// Time is expressed in the engine's normalized 10 ms units. + /// + internal static void AdvanceCoil(ref MagnetState magnet, float physicsDiffTime) + { + var command = magnet.IsEnabled ? math.saturate(magnet.CommandedPower) : 0f; + if (!UsesPhysicalResponse(in magnet)) { + magnet.EffectiveCurrent = command; + magnet.EffectiveStrength = magnet.Strength * command; + return; + } + + var timeConstant = command > magnet.EffectiveCurrent ? magnet.RiseTime : magnet.FallTime; + if (timeConstant <= MinDistance || physicsDiffTime <= 0f) { + magnet.EffectiveCurrent = command; + } else { + // Implicit Euler is stable for long frames and approximates the RL + // exponential without a transcendental in the 1 kHz loop. + var alpha = math.saturate(physicsDiffTime / (timeConstant + physicsDiffTime)); + magnet.EffectiveCurrent = math.lerp(magnet.EffectiveCurrent, command, alpha); + if (math.abs(magnet.EffectiveCurrent - command) <= MinEffectiveCurrent) { + magnet.EffectiveCurrent = command; + } + } + magnet.EffectiveStrength = magnet.Strength * magnet.EffectiveCurrent * magnet.EffectiveCurrent; + } + internal static float3 RefreshKinematicState(int itemId, ref MagnetState magnet, ref PhysicsState state) { if (!magnet.IsKinematic || !state.KinematicTransforms.TryGetValue(itemId, out var matrix)) { @@ -180,7 +211,7 @@ internal static void ApplyVpxCompatibleForce(ref BallState ball, in MagnetState // cvpmMagnet.AttractBall: ratio = dist / (1.5*Size), then the damping wraps // both the old velocity and the impulse: (vel - dir*force) * 0.985 var ratio = distance / (1.5f * magnet.Radius); - var force = magnet.Strength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; + var force = magnet.EffectiveStrength * math.exp(-0.2f / ratio) / (ratio * ratio * 56f) * 1.5f; var direction = delta / distance; var velocity = (ball.Velocity.xy - direction * force * physicsDiffTime) * damping; @@ -197,7 +228,7 @@ internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magne var distance = math.sqrt(distanceSq); var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); - var force = magnet.Strength / (effectiveDistance * effectiveDistance); + var force = magnet.EffectiveStrength / (effectiveDistance * effectiveDistance); var direction = delta / distance; var velocity = ball.Velocity.xy - direction * force * physicsDiffTime; @@ -217,7 +248,7 @@ internal static void ApplySpatialPhysicalForce(ref BallState ball, in MagnetStat var distance = math.sqrt(distanceSq); var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); - var force = magnet.Strength / (effectiveDistance * effectiveDistance); + var force = magnet.EffectiveStrength / (effectiveDistance * effectiveDistance); var direction = delta / distance; var velocity = ball.Velocity - direction * force * physicsDiffTime; @@ -247,7 +278,7 @@ internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState { var offset = ball.Position - Center3D(in magnet); var relativeVelocity = ball.Velocity - magnetVelocity; - var holdStrength = math.max(math.abs(magnet.Strength), PhysicalMinimumHoldStrength); + var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); @@ -269,7 +300,7 @@ internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet var offset = ball.Position.xy - magnet.Position; var velocity = ball.Velocity.xy; var relativeVelocity = velocity - magnetVelocity; - var holdStrength = math.max(math.abs(magnet.Strength), PhysicalMinimumHoldStrength); + var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); @@ -363,7 +394,7 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt ? math.lengthsq(ball.Position - Center3D(in magnet)) : math.lengthsq(ball.Position.xy - magnet.Position); var isInGrabRange = magnet.GrabRadius > 0f && - magnet.Strength > 0f && + magnet.EffectiveStrength > 0f && distanceSq <= magnet.GrabRadius * magnet.GrabRadius; if (!isInGrabRange) { @@ -405,6 +436,14 @@ private static float PhysicalCoreRadius(in MagnetState magnet) return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); } + private static bool UsesPhysicalResponse(in MagnetState magnet) + => magnet.MagnetType == MagnetType.Spatial || magnet.Profile == MagnetForceProfile.Physical; + + private static bool HasActiveField(in MagnetState magnet) + => UsesPhysicalResponse(in magnet) + ? magnet.EffectiveCurrent > MinEffectiveCurrent && math.abs(magnet.Strength) > MinDistance + : magnet.IsEnabled && magnet.CommandedPower > 0f; + private static float3 GetKinematicVelocity(int itemId, in MagnetState magnet, ref PhysicsState state) { return state.GetKinematicVelocityAt(itemId, Center3D(in magnet)); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 4ae52f4aa..2aeb0bd62 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -26,6 +26,11 @@ internal struct MagnetState internal float Height; internal float Radius; internal float Strength; + internal float CommandedPower; + internal float EffectiveCurrent; + internal float EffectiveStrength; + internal float RiseTime; + internal float FallTime; internal float GrabRadius; internal float PlanarDamping; [MarshalAs(UnmanagedType.U1)] From 092be9df8ab03e5ecdeb74244facdfeaa4820444 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:31:09 +0200 Subject: [PATCH 44/50] physics(magnets): shape a finite annular pole field Replace the softened point inverse-square law with a finite axisymmetric pole kernel. Its lateral force vanishes on-axis, peaks in an annulus near the authored pole radius, weakens with vertical air gap, and decays with a dipole-like far field without evaluating elliptic integrals. Apply a squared compact-support taper at Radius and Height Range so distant balls are rejected before the field math and the force reaches zero smoothly at the boundary. Physical and Spatial profiles now treat polarity as attraction magnitude for an ordinary steel ball, while signed repulsion remains available only through VPX Compatible. --- .../Assets/Resources/Prefabs/Magnet.prefab | 1 + .../manual/mechanisms/magnets.md | 13 ++-- .../VPT/Magnet/MagnetInspector.cs | 3 + .../Physics/MagnetPhysicsTests.cs | 71 +++++++++++++++---- .../VPT/Magnet/MagnetComponent.cs | 14 ++++ .../VPT/Magnet/MagnetPackable.cs | 3 + .../VPT/Magnet/MagnetPhysics.cs | 48 ++++++++++--- .../VPT/Magnet/MagnetState.cs | 1 + 8 files changed, 127 insertions(+), 27 deletions(-) diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab index 90556e17d..0ac63abc9 100644 --- a/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/Magnet.prefab @@ -50,6 +50,7 @@ MonoBehaviour: ForceProfile: 0 CoilRiseTime: 20 CoilFallTime: 20 + PoleRadius: 10 GrabBall: 0 GrabRadius: 10.8 HeightRange: 50 diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index 8f10a5eca..c51a1f149 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -1,14 +1,14 @@ --- uid: magnets title: Magnets and Turntables -description: Native playfield magnets, ball holds, repel/eject behavior, and spinning disc turntables. +description: Native playfield magnets, ball holds, VPX repel/eject behavior, and spinning disc turntables. --- # Magnets and Turntables VPE provides native mechanisms for playfield magnets and spinning magnetic discs. They run inside the physics loop, so they can react every physics tick instead of waiting for a script timer. -Use **Magnet** for radial attraction, grab/hold behavior, repulsion, and eject-style mechanisms. Use **Turntable** for a spinning disc that pushes the ball tangentially, such as VPX `cvpmTurnTable` mechanisms. +Use **Magnet** for radial attraction, grab/hold behavior, VPX-compatible repulsion, and eject-style mechanisms. Use **Turntable** for a spinning disc that pushes the ball tangentially, such as VPX `cvpmTurnTable` mechanisms. ## Magnet Setup @@ -22,7 +22,8 @@ The selected object shows a radius gizmo in the scene view. Playfield magnets dr | **Radius** | Influence radius in millimeters. Playfield magnets use a planar radius; spatial magnets use a spherical radius. | | **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. Spatial magnets ignore this field. | | **Strength** | Full-power magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values and negative values repel. | -| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that want a smoother inverse-square force. Spatial magnets always use physical 3-D force semantics. | +| **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that use the finite-pole field model. Spatial magnets always use physical 3-D force semantics. | +| **Pole Radius** | Effective radius of the circular or annular pole face. The Physical force is strongest in a ring near this pole and is zero laterally at the exact center. | | **Coil Rise Time** | Electrical rise time constant in milliseconds for Physical and Spatial magnets. Current reaches about 63% after one time constant. | | **Coil Fall Time** | Electrical decay time constant in milliseconds for Physical and Spatial magnets. Tune this to the coil driver and flyback circuit. | | **Grab Ball** | Enables center hold behavior inside the grab radius. | @@ -59,7 +60,11 @@ This mode is the default. It is the right choice for ROM-controlled magnets and ### Physical -Use **Physical** for new VPE-authored tables. Its electrical response ramps toward the commanded current instead of switching the field instantaneously, and the field decays after power is removed. Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. +Use **Physical** for new VPE-authored tables. Its electrical response ramps toward the commanded current instead of switching the field instantaneously, and the field decays after power is removed. The force uses a finite axisymmetric pole approximation: lateral force is zero on the center axis, strongest in an annulus near **Pole Radius**, weakens with vertical air gap, and falls rapidly outside the pole. **Radius** is a hard performance boundary with a smooth force taper, so the magnet never evaluates an infinite tail. + +Physical magnets always attract an ordinary steel pinball. Reversing coil polarity changes magnetic flux direction but not the direction of attraction. Use VPX Compatible when a legacy script intentionally uses negative strength as a fictional repelling force. + +Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index c70143b1b..a68e501f4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -28,6 +28,7 @@ public class MagnetInspector : ItemInspector private SerializedProperty _forceProfileProperty; private SerializedProperty _coilRiseTimeProperty; private SerializedProperty _coilFallTimeProperty; + private SerializedProperty _poleRadiusProperty; private SerializedProperty _grabBallProperty; private SerializedProperty _grabRadiusProperty; private SerializedProperty _heightRangeProperty; @@ -47,6 +48,7 @@ protected override void OnEnable() _forceProfileProperty = serializedObject.FindProperty(nameof(MagnetComponent.ForceProfile)); _coilRiseTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilRiseTime)); _coilFallTimeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoilFallTime)); + _poleRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.PoleRadius)); _grabBallProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabBall)); _grabRadiusProperty = serializedObject.FindProperty(nameof(MagnetComponent.GrabRadius)); _heightRangeProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeightRange)); @@ -75,6 +77,7 @@ public override void OnInspectorGUI() } var usesPhysicalResponse = isSpatial || _forceProfileProperty.enumValueIndex == (int)MagnetForceProfile.Physical; if (usesPhysicalResponse) { + PropertyField(_poleRadiusProperty); PropertyField(_coilRiseTimeProperty); PropertyField(_coilFallTimeProperty); } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 61d8a2191..92f308a5c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -144,44 +144,90 @@ public void VpxCompatibleForceRepelsWithNegativeStrength() } [Test] - public void PhysicalForceSaturatesInsideCoreRadius() + public void PhysicalForcePeaksAroundFinitePoleAndDecays() + { + var axisBall = CreateBall(); + var poleBall = CreateBall(); + var farBall = CreateBall(); + axisBall.Position = new float3(0f, 0f, 0f); + poleBall.Position = new float3(9f, 0f, 0f); + farBall.Position = new float3(60f, 0f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref axisBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref poleBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref farBall, in magnet, 1f); + + Assert.That(axisBall.Velocity.x, Is.EqualTo(0f), "lateral force is zero on the symmetry axis"); + Assert.That(poleBall.Velocity.x, Is.LessThan(0f)); + Assert.That(math.abs(poleBall.Velocity.x), Is.GreaterThan(math.abs(farBall.Velocity.x))); + } + + [Test] + public void PhysicalForceWeakensWithAirGap() { var nearBall = CreateBall(); - var coreBall = CreateBall(); - nearBall.Position = new float3(1f, 0f, 10f); - coreBall.Position = new float3(20f, 0f, 10f); + var highBall = CreateBall(); + nearBall.Position = new float3(10f, 0f, 5f); + highBall.Position = new float3(10f, 0f, 40f); var magnet = new MagnetState { Position = float2.zero, + Height = 0f, + HeightRange = 100f, Radius = 100f, Strength = 400f, - EffectiveStrength = 400f + EffectiveStrength = 400f, + PoleRadius = 20f }; MagnetPhysics.ApplyPhysicalForce(ref nearBall, in magnet, 1f); - MagnetPhysics.ApplyPhysicalForce(ref coreBall, in magnet, 1f); + MagnetPhysics.ApplyPhysicalForce(ref highBall, in magnet, 1f); - Assert.That(nearBall.Velocity.x, Is.EqualTo(coreBall.Velocity.x).Within(1e-5f)); - Assert.That(nearBall.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); - Assert.That(coreBall.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(math.abs(nearBall.Velocity.x), Is.GreaterThan(math.abs(highBall.Velocity.x))); } [Test] - public void PhysicalForceRepelsWithNegativeStrength() + public void PhysicalForceAttractsWithNegativeAuthoredStrength() { var ball = CreateBall(); var magnet = new MagnetState { Position = float2.zero, Radius = 100f, Strength = -400f, - EffectiveStrength = -400f + EffectiveStrength = -400f, + PoleRadius = 20f }; MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); - Assert.That(ball.Velocity.x, Is.GreaterThan(0f)); + Assert.That(ball.Velocity.x, Is.LessThan(0f)); Assert.That(ball.Velocity.y, Is.EqualTo(0f).Within(1e-5f)); } + [Test] + public void PhysicalForceHasCompactRadiusCutoff() + { + var ball = CreateBall(); + ball.Position = new float3(100f, 0f, 0f); + var magnet = new MagnetState { + Position = float2.zero, + Radius = 100f, + Strength = 400f, + EffectiveStrength = 400f, + PoleRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalForce(ref ball, in magnet, 1f); + + Assert.That(ball.Velocity, Is.EqualTo(float3.zero)); + } + [Test] public void SpatialRangeUsesSphericalDistance() { @@ -211,6 +257,7 @@ public void SpatialPhysicalForcePullsInThreeDimensions() Radius = 100f, Strength = 400f, EffectiveStrength = 400f, + PoleRadius = 20f, MagnetType = MagnetType.Spatial }; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 6f982aadc..3c9d61999 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -35,6 +35,7 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic public const float DefaultPlanarDamping = 0.985f; public const float DefaultCoilRiseTimeMs = 20f; public const float DefaultCoilFallTimeMs = 20f; + public const float DefaultPoleRadiusMm = 10f; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -62,6 +63,11 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("Electrical decay time constant for Physical and Spatial magnets. Set this to match the driver flyback circuit.")] public float CoilFallTime = DefaultCoilFallTimeMs; + [Min(0f)] + [Unit("mm")] + [Tooltip("Effective radius of the circular or annular pole face used by Physical and Spatial field profiles.")] + public float PoleRadius = DefaultPoleRadiusMm; + [Tooltip("Whether the magnet can hold a ball at its center.")] public bool GrabBall; @@ -142,6 +148,7 @@ private void OnValidate() HeightRange = math.max(0f, HeightRange); CoilRiseTime = math.max(0f, CoilRiseTime); CoilFallTime = math.max(0f, CoilFallTime); + PoleRadius = math.max(0f, PoleRadius); SyncPhysicsState(); } @@ -160,6 +167,7 @@ internal MagnetState CreateState() EffectiveStrength = usesPhysicalResponse ? 0f : Strength * commandedPower, RiseTime = CoilRiseTime / MagnetPhysics.VpxMagnetUpdateMs, FallTime = CoilFallTime / MagnetPhysics.VpxMagnetUpdateMs, + PoleRadius = MillimetersToVpx(PoleRadius), GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), IsEnabled = IsEnabledOnStart, @@ -230,6 +238,7 @@ internal static float3 GetPlayfieldPositionVpx(Transform transform) private void OnDrawGizmosSelected() { var radiusWorld = Radius * MillimetersToWorld; + var poleRadiusWorld = PoleRadius * MillimetersToWorld; var grabRadiusWorld = GrabBall ? GrabRadius * MillimetersToWorld : 0f; var heightRangeWorld = HeightRange * MillimetersToWorld; @@ -249,6 +258,11 @@ private void OnDrawGizmosSelected() } } + if ((MagnetType == VisualPinball.Unity.MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical) && poleRadiusWorld > 0f) { + Gizmos.color = new Color(0.75f, 0.2f, 1f, 0.55f); + DrawLocalDisc(poleRadiusWorld); + } + if (MagnetType == VisualPinball.Unity.MagnetType.Playfield && heightRangeWorld > 0f) { Gizmos.color = new Color(0.1f, 0.55f, 1f, 0.35f); DrawLocalCylinder(radiusWorld, heightRangeWorld); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index f5e649c0b..7b989be34 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -24,6 +24,7 @@ public struct MagnetPackable public MagnetForceProfile ForceProfile; public float CoilRiseTime; public float CoilFallTime; + public float PoleRadius; public bool GrabBall; public float GrabRadius; public float HeightRange; @@ -40,6 +41,7 @@ public static byte[] Pack(MagnetComponent comp) ForceProfile = comp.ForceProfile, CoilRiseTime = comp.CoilRiseTime, CoilFallTime = comp.CoilFallTime, + PoleRadius = comp.PoleRadius, GrabBall = comp.GrabBall, GrabRadius = comp.GrabRadius, HeightRange = comp.HeightRange, @@ -58,6 +60,7 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) comp.ForceProfile = data.ForceProfile; comp.CoilRiseTime = data.CoilRiseTime; comp.CoilFallTime = data.CoilFallTime; + comp.PoleRadius = data.PoleRadius; comp.GrabBall = data.GrabBall; comp.GrabRadius = data.GrabRadius; comp.HeightRange = data.HeightRange; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 913161de1..162b7837a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -27,8 +27,8 @@ internal static class MagnetPhysics internal const float VpxMagnetUpdateMs = 10f; private const float MinDistance = 0.0001f; private const float MinDistanceSq = MinDistance * MinDistance; - private const float PhysicalCoreRadiusRatio = 0.2f; - private const float PhysicalMinimumCoreRadius = 5f; + private const float PhysicalMinimumPoleRadius = 1f; + private const float AnnularPoleKernelNormalization = 3.864f; private const float PhysicalVelocityDamping = 0.02f; private const float PhysicalMinimumHoldStrength = 1f; private const float MinEffectiveCurrent = 0.0001f; @@ -227,8 +227,12 @@ internal static void ApplyPhysicalForce(ref BallState ball, in MagnetState magne } var distance = math.sqrt(distanceSq); - var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); - var force = magnet.EffectiveStrength / (effectiveDistance * effectiveDistance); + var height = math.max(0f, ball.Position.z - magnet.Height); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + if (magnet.HeightRange > 0f) { + cutoff *= CompactSupport(height * height, magnet.HeightRange * magnet.HeightRange); + } + var force = PhysicalForceMagnitude(distance, height, cutoff, in magnet); var direction = delta / distance; var velocity = ball.Velocity.xy - direction * force * physicsDiffTime; @@ -247,8 +251,8 @@ internal static void ApplySpatialPhysicalForce(ref BallState ball, in MagnetStat } var distance = math.sqrt(distanceSq); - var effectiveDistance = math.max(distance, PhysicalCoreRadius(in magnet)); - var force = magnet.EffectiveStrength / (effectiveDistance * effectiveDistance); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + var force = PhysicalForceMagnitude(distance, 0f, cutoff, in magnet); var direction = delta / distance; var velocity = ball.Velocity - direction * force * physicsDiffTime; @@ -279,7 +283,7 @@ internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState var offset = ball.Position - Center3D(in magnet); var relativeVelocity = ball.Velocity - magnetVelocity; var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); - var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); + var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumPoleRadius); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; @@ -301,7 +305,7 @@ internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet var velocity = ball.Velocity.xy; var relativeVelocity = velocity - magnetVelocity; var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); - var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumCoreRadius); + var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumPoleRadius); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; @@ -394,7 +398,7 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt ? math.lengthsq(ball.Position - Center3D(in magnet)) : math.lengthsq(ball.Position.xy - magnet.Position); var isInGrabRange = magnet.GrabRadius > 0f && - magnet.EffectiveStrength > 0f && + math.abs(magnet.EffectiveStrength) > MinDistance && distanceSq <= magnet.GrabRadius * magnet.GrabRadius; if (!isInGrabRange) { @@ -431,9 +435,31 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt return true; } - private static float PhysicalCoreRadius(in MagnetState magnet) + /// + /// Compact approximation to the lateral gradient of B squared above a finite, + /// axisymmetric pole face. The radial force is zero on-axis, strongest in an + /// annulus around the pole, and falls with the fifth power in the far field. + /// + internal static float PhysicalForceMagnitude(float radialDistance, float axialDistance, float cutoff, in MagnetState magnet) + { + if (cutoff <= 0f) { + return 0f; + } + var poleRadius = math.max(PhysicalMinimumPoleRadius, magnet.PoleRadius); + var radialRatio = radialDistance / poleRadius; + var axialRatio = axialDistance / poleRadius; + var denominator = 1f + radialRatio * radialRatio + axialRatio * axialRatio; + var kernel = AnnularPoleKernelNormalization * radialRatio / (denominator * denominator * denominator); + return math.abs(magnet.EffectiveStrength) * kernel * cutoff / (poleRadius * poleRadius); + } + + private static float CompactSupport(float distanceSq, float radiusSq) { - return math.max(PhysicalMinimumCoreRadius, magnet.Radius * PhysicalCoreRadiusRatio); + if (radiusSq <= MinDistanceSq || distanceSq >= radiusSq) { + return 0f; + } + var remaining = 1f - distanceSq / radiusSq; + return remaining * remaining; } private static bool UsesPhysicalResponse(in MagnetState magnet) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 2aeb0bd62..114049dd2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -31,6 +31,7 @@ internal struct MagnetState internal float EffectiveStrength; internal float RiseTime; internal float FallTime; + internal float PoleRadius; internal float GrabRadius; internal float PlanarDamping; [MarshalAs(UnmanagedType.U1)] From 9a4bce07543d368b1717b0ad633fd60f12266df1 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:34:05 +0200 Subject: [PATCH 45/50] physics(magnets): gate grabs on available force Keep the geometric snap for VPX Compatible, but require Physical and Spatial magnets to be able to arrest the ball relative to the moving hold point within the remaining grab volume. Fast fly-bys stay under the attraction force until they slow enough to be retained instead of firing a grab event on radius entry. Remove the minimum hold-strength boost and cap the spring impulse with the actual current-derived field. Weak PWM commands therefore remain weak, while existing grabbed balls still release naturally when a collision carries them outside the grab radius. --- .../manual/mechanisms/magnets.md | 6 +- .../Physics/MagnetPhysicsTests.cs | 68 +++++++++++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 41 +++++++++-- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index c51a1f149..4f4b3826e 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -27,7 +27,7 @@ The selected object shows a radius gizmo in the scene view. Playfield magnets dr | **Coil Rise Time** | Electrical rise time constant in milliseconds for Physical and Spatial magnets. Current reaches about 63% after one time constant. | | **Coil Fall Time** | Electrical decay time constant in milliseconds for Physical and Spatial magnets. Tune this to the coil driver and flyback circuit. | | **Grab Ball** | Enables center hold behavior inside the grab radius. | -| **Grab Radius** | Radius where the magnet starts holding the ball. VPX Compatible playfield mode clamps to center; Physical playfield mode uses a spring-damper hold; Spatial mode uses a 3-D spring-damper hold that a hard hit can overcome. | +| **Grab Radius** | Capture volume for grab mode. VPX Compatible snaps on entry. Physical and Spatial magnets capture only when the current field can arrest the ball before it leaves this volume, then use a spring-damper hold that a hard hit can overcome. | | **Is Enabled On Start** | Starts the magnet on before a coil or script changes it. | | **Is Kinematic** | Moves the magnetic field with the GameObject transform during gameplay. Use this when the magnet is mounted on a moving mech. | | **Draw Debug Forces** | Draws play-mode force vectors for balls inside the radius. | @@ -64,7 +64,7 @@ Use **Physical** for new VPE-authored tables. Its electrical response ramps towa Physical magnets always attract an ordinary steel pinball. Reversing coil polarity changes magnetic flux direction but not the direction of attraction. Use VPX Compatible when a legacy script intentionally uses negative strength as a fictional repelling force. -Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. +Physical grab uses a capped spring-damper hold, so the ball visibly decelerates instead of snapping to the center. Capture is based on relative speed, remaining grab distance, and effective field strength; a fast fly-by or weak PWM command does not become an artificial full-strength hold. Physical strength values are not VPX strength values. Start with a larger value than you would use in VPX Compatible mode and tune by watching ball speed and catch behavior in play mode. @@ -74,7 +74,7 @@ Use **Magnet Type: Spatial** for mechanisms that physically carry the ball away When **Grab Ball** is enabled, a ball inside **Grab Radius** is pulled to that 3-D hold point by a strong magnetic force and held there. The ball stays a live physics object throughout — it is not frozen — so it renders at its real position and other balls collide with it normally. If **Is Kinematic** is enabled and the GameObject moves during gameplay, the hold force drags the ball along in x, y, and z. Turning the coil off or calling `ReleaseBall()` simply drops the hold, and the ball continues with whatever velocity it has. `Eject(speed, angleDeg, verticalAngleDeg)` throws it directionally; the first angle uses the same convention as kickers, and the optional vertical angle lifts or drops the shot. -Because the hold is a force rather than a rigid lock, a hard enough hit from another ball can overcome it and knock the ball loose (and that ball can in turn be grabbed). **Strength** sets how hard the magnet holds — a stronger magnet needs a harder hit to dislodge its ball. This is not a levitation model: use **Radius** and **Strength** to catch a nearby ball, not to pull one across the table or balance it far below the hold point. +Because the hold is a force rather than a rigid lock, a hard enough hit from another ball can overcome it and knock the ball loose (and that ball can in turn be grabbed). A new ball is marked held only after the current field can arrest its relative motion inside **Grab Radius**. **Strength** sets how hard the magnet holds — a stronger magnet needs a harder hit to dislodge its ball. This is not a levitation model: use **Radius** and **Strength** to catch a nearby ball, not to pull one across the table or balance it far below the hold point. ## Turntable Setup diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 92f308a5c..4e2bc410d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -333,6 +333,58 @@ public void PhysicalHoldPullsBallWithoutTeleporting() Assert.That(ball.AngularMomentum.y, Is.LessThan(1f)); } + [Test] + public void PhysicalHoldDoesNotAmplifyWeakCurrent() + { + var ball = CreateBall(); + ball.Position = new float3(10f, 0f, 10f); + ball.Velocity = float3.zero; + var magnet = new MagnetState { + Position = float2.zero, + EffectiveStrength = 0.1f, + PoleRadius = 20f, + GrabRadius = 20f + }; + + MagnetPhysics.ApplyPhysicalHold(ref ball, in magnet, 0.1f); + + Assert.That(math.abs(ball.Velocity.x), Is.LessThanOrEqualTo(0.01f), "the hold cap must not exceed the effective field"); + } + + [Test] + public void PhysicalGrabRejectsFastFlyby() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(10f, 0f, 10f), + Velocity = new float3(100f, 0f, 0f) + }); + var magnet = CreatePhysicalGrabMagnet(); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + Assert.That(magnet.GrabbedBalls.Value, Is.EqualTo(0UL)); + } + + [Test] + public void PhysicalGrabCapturesRetainableBall() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = new float3(10f, 0f, 10f), + Velocity = new float3(1f, 0f, 0f) + }); + var magnet = CreatePhysicalGrabMagnet(); + + MagnetPhysics.Update(17, ref magnet, ref state, 0.1f); + + Assert.That(magnet.GrabbedBalls.Value, Is.Not.EqualTo(0UL)); + } + [Test] public void SpatialGrabHoldsBallWithForceNotFreeze() { @@ -694,6 +746,22 @@ private static BallState CreateBall() Velocity = new float3(0f, 0f, 0f) }; } + + private static MagnetState CreatePhysicalGrabMagnet() + { + return new MagnetState { + Position = float2.zero, + Height = 0f, + Radius = 100f, + HeightRange = 50f, + Strength = 20f, + CommandedPower = 1f, + PoleRadius = 20f, + GrabRadius = 20f, + IsEnabled = true, + Profile = MagnetForceProfile.Physical + }; + } } /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 162b7837a..2a13b90aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -30,7 +30,6 @@ internal static class MagnetPhysics private const float PhysicalMinimumPoleRadius = 1f; private const float AnnularPoleKernelNormalization = 3.864f; private const float PhysicalVelocityDamping = 0.02f; - private const float PhysicalMinimumHoldStrength = 1f; private const float MinEffectiveCurrent = 0.0001f; [BurstCompile] @@ -282,8 +281,11 @@ internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState { var offset = ball.Position - Center3D(in magnet); var relativeVelocity = ball.Velocity - magnetVelocity; - var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); - var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumPoleRadius); + var holdStrength = math.abs(magnet.EffectiveStrength); + if (holdStrength <= MinDistance) { + return; + } + var holdRadius = math.max(magnet.GrabRadius, math.max(magnet.PoleRadius, PhysicalMinimumPoleRadius)); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; @@ -304,8 +306,11 @@ internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet var offset = ball.Position.xy - magnet.Position; var velocity = ball.Velocity.xy; var relativeVelocity = velocity - magnetVelocity; - var holdStrength = math.max(math.abs(magnet.EffectiveStrength), PhysicalMinimumHoldStrength); - var holdRadius = math.max(magnet.GrabRadius, PhysicalMinimumPoleRadius); + var holdStrength = math.abs(magnet.EffectiveStrength); + if (holdStrength <= MinDistance) { + return; + } + var holdRadius = math.max(magnet.GrabRadius, math.max(magnet.PoleRadius, PhysicalMinimumPoleRadius)); var stiffness = holdStrength / holdRadius; var damping = 2f * math.sqrt(stiffness); var impulse = (-offset * stiffness - relativeVelocity * damping) * physicsDiffTime; @@ -394,12 +399,16 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ball.Id); var isGrabbed = magnet.GrabbedBalls.IsSet(bitIndex); + var usesPhysicalCapture = UsesPhysicalResponse(in magnet); var distanceSq = magnet.MagnetType == MagnetType.Spatial ? math.lengthsq(ball.Position - Center3D(in magnet)) : math.lengthsq(ball.Position.xy - magnet.Position); + var hasGrabForce = usesPhysicalCapture + ? math.abs(magnet.EffectiveStrength) > MinDistance + : magnet.EffectiveStrength > 0f; var isInGrabRange = magnet.GrabRadius > 0f && - math.abs(magnet.EffectiveStrength) > MinDistance && - distanceSq <= magnet.GrabRadius * magnet.GrabRadius; + hasGrabForce && + distanceSq <= magnet.GrabRadius * magnet.GrabRadius; if (!isInGrabRange) { magnet.ReleasedBalls.SetBits(bitIndex, false); @@ -414,6 +423,9 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt } if (!isGrabbed) { + if (usesPhysicalCapture && !CanCapturePhysical(in ball, in magnet, distanceSq, magnetVelocity)) { + return false; + } magnet.GrabbedBalls.SetBits(bitIndex, true); state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ball.Id, true)); } @@ -435,6 +447,21 @@ private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsSt return true; } + /// + /// A physical grab starts only when the available hold acceleration can arrest + /// relative motion before the ball crosses the remaining grab volume. + /// + internal static bool CanCapturePhysical(in BallState ball, in MagnetState magnet, float distanceSq, float3 magnetVelocity) + { + var distance = math.sqrt(math.max(0f, distanceSq)); + var remainingDistance = math.max(0f, magnet.GrabRadius - distance); + var relativeSpeedSq = magnet.MagnetType == MagnetType.Spatial + ? math.lengthsq(ball.Velocity - magnetVelocity) + : math.lengthsq(ball.Velocity.xy - magnetVelocity.xy); + var availableAcceleration = math.abs(magnet.EffectiveStrength); + return relativeSpeedSq <= 2f * availableAcceleration * remainingDistance; + } + /// /// Compact approximation to the lateral gradient of B squared above a finite, /// axisymmetric pole face. The radial force is zero on-axis, strongest in an From 27a0d05829055305501bd4ebe0130ab4c267387f Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:36:31 +0200 Subject: [PATCH 46/50] runtime(turntables): publish visual state snapshots Publish turntable speed and rotation angle as one float2 animation value in both single-threaded movement application and the external-timing triple buffer. TurntableComponent now renders from that main-thread value, and the public API exposes the same published pair. Remove the visual and getter reads from the live NativeParallelHashMap while the simulation thread may be writing it. Expand the float2 snapshot capacity for turntables and cover the paired movement handoff with a focused test. --- .../Physics/TurntablePhysicsTests.cs | 38 +++++++++++++++++++ .../Game/PhysicsEngineThreading.cs | 14 ++++++- .../Game/PhysicsMovements.cs | 19 ++++++++-- .../Simulation/SimulationState.cs | 4 +- .../VPT/Turntable/TurntableApi.cs | 14 +------ .../VPT/Turntable/TurntableComponent.cs | 19 +++++++++- 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs index 05ad14b56..88e27df93 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System; +using System.Collections.Generic; using NUnit.Framework; +using Unity.Collections; using Unity.Mathematics; namespace VisualPinball.Unity.Test @@ -89,6 +92,29 @@ public void KinematicTransformUpdatesTurntableCenterAndHeight() Assert.That(turntable.Height, Is.EqualTo(3f).Within(1e-5f)); } + [Test] + public void MovementPublishesSpeedAndAngleTogether() + { + var states = new NativeParallelHashMap(1, Allocator.Temp); + try { + states.Add(17, new TurntableState { + Speed = 42f, + RotationAngle = 123f + }); + var emitter = new Float2Emitter(); + var emitters = new Dictionary> { + { 17, emitter } + }; + var movements = new PhysicsMovements(); + + movements.ApplyTurntableMovement(ref states, emitters); + + Assert.That(emitter.Value, Is.EqualTo(new float2(42f, 123f))); + } finally { + states.Dispose(); + } + } + private static BallState CreateBall() { return new BallState { @@ -97,5 +123,17 @@ private static BallState CreateBall() Velocity = new float3(0f, 0f, 5f) }; } + + private sealed class Float2Emitter : IAnimationValueEmitter + { + public float2 Value; + public event Action OnAnimationValueChanged; + + public void UpdateAnimationValue(float2 value) + { + Value = value; + OnAnimationValueChanged?.Invoke(value); + } + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 02bbf73d4..1e5d83291 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -90,6 +90,7 @@ private struct HeldKinematicPose private readonly int[] _snapshotFlipperIds; private readonly int[] _snapshotBumperRingIds; private readonly int[] _snapshotBumperSkirtIds; + private readonly int[] _snapshotTurntableIds; private readonly int[] _snapshotDropTargetIds; private readonly int[] _snapshotHitTargetIds; private readonly int[] _snapshotGateIds; @@ -119,6 +120,7 @@ internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContex _snapshotFlipperIds = SnapshotIds(_ctx.FlipperStates.Ref); _snapshotBumperRingIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.RingItemId != 0); _snapshotBumperSkirtIds = SnapshotIds(_ctx.BumperStates.Ref, static state => state.SkirtItemId != 0); + _snapshotTurntableIds = SnapshotIds(_ctx.TurntableStates.Ref); _snapshotDropTargetIds = SnapshotIds(_ctx.DropTargetStates.Ref, static state => state.AnimatedItemId != 0); _snapshotHitTargetIds = SnapshotIds(_ctx.HitTargetStates.Ref, static state => state.AnimatedItemId != 0); _snapshotGateIds = SnapshotIds(_ctx.GateStates.Ref); @@ -601,9 +603,9 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) Logger.Warn($"[PhysicsEngine] Float animation snapshot capacity exceeded: {snapshot.FloatAnimationSourceCount} channels for max {SimulationState.MaxFloatAnimations}. Snapshot output is truncated."); } - // --- Float2 animations (bumper skirts) --- + // --- Float2 animations (bumper skirts and turntable speed/angle pairs) --- var float2Count = 0; - snapshot.Float2AnimationSourceCount = _snapshotBumperSkirtIds.Length; + snapshot.Float2AnimationSourceCount = _snapshotBumperSkirtIds.Length + _snapshotTurntableIds.Length; for (var i = 0; i < _snapshotBumperSkirtIds.Length && float2Count < SimulationState.MaxFloat2Animations; i++) { var itemId = _snapshotBumperSkirtIds[i]; ref var s = ref _ctx.BumperStates.Ref.GetValueByRef(itemId); @@ -611,6 +613,13 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) ItemId = itemId, Value = s.SkirtAnimation.Rotation }; } + for (var i = 0; i < _snapshotTurntableIds.Length && float2Count < SimulationState.MaxFloat2Animations; i++) { + var itemId = _snapshotTurntableIds[i]; + ref var s = ref _ctx.TurntableStates.Ref.GetValueByRef(itemId); + snapshot.Float2Animations[float2Count++] = new SimulationState.Float2Animation { + ItemId = itemId, Value = new float2(s.Speed, s.RotationAngle) + }; + } snapshot.Float2AnimationCount = float2Count; snapshot.Float2AnimationsTruncated = snapshot.Float2AnimationSourceCount > SimulationState.MaxFloat2Animations ? (byte)1 : (byte)0; if (!_float2SnapshotOverflowWarningIssued && snapshot.Float2AnimationsTruncated != 0) { @@ -909,6 +918,7 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents); + _physicsMovements.ApplyTurntableMovement(ref _ctx.TurntableStates.Ref, _ctx.Float2AnimatedComponents); _physicsEngine.ApplyVisualNudge(_ctx.PhysicsEnv.Nudge.CabinetOffset); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs index 6037d0861..01d94a963 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs @@ -129,7 +129,7 @@ internal void ApplySpinnerMovement(ref NativeParallelHashMap } } - internal void ApplyTriggerMovement(ref NativeParallelHashMap triggerStates, + internal void ApplyTriggerMovement(ref NativeParallelHashMap triggerStates, Dictionary> floatAnimatedComponent) { using var enumerator = triggerStates.GetEnumerator(); @@ -141,6 +141,17 @@ internal void ApplyTriggerMovement(ref NativeParallelHashMap var component = floatAnimatedComponent[enumerator.Current.Key]; component.UpdateAnimationValue(triggerState.Movement.HeightOffset); } - } - } -} + } + + internal void ApplyTurntableMovement(ref NativeParallelHashMap turntableStates, + Dictionary> float2AnimatedComponent) + { + using var enumerator = turntableStates.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var turntableState = ref enumerator.Current.Value; + var component = float2AnimatedComponent[enumerator.Current.Key]; + component.UpdateAnimationValue(new float2(turntableState.Speed, turntableState.RotationAngle)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs index 0d4da20c4..f2af18f85 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs @@ -48,9 +48,9 @@ public class SimulationState : IDisposable internal const int MaxFloatAnimations = 128; /// - /// Maximum number of float2-animated items (bumper skirts) + /// Maximum number of float2-animated items (bumper skirts and turntables) /// - internal const int MaxFloat2Animations = 16; + internal const int MaxFloat2Animations = 32; #region Animation Snapshot Structures diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs index 23f5250b0..f746eb3fc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableApi.cs @@ -59,21 +59,11 @@ public bool SpinClockwise { } public float Speed { - get { - if (!_physicsEngine) { - return 0f; - } - return _physicsEngine.TurntableState(_itemId).Speed; - } + get => _component.PublishedSpeed; } public float RotationAngle { - get { - if (!_physicsEngine) { - return 0f; - } - return _physicsEngine.TurntableState(_itemId).RotationAngle; - } + get => _component.PublishedRotationAngle; } public float MaxSpeed { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 01826a337..5c8589394 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System; using System.Collections.Generic; using NLog; using Unity.Mathematics; @@ -27,7 +28,7 @@ namespace VisualPinball.Unity [PackAs("Turntable")] [AddComponentMenu("Pinball/Mechs/Turntable")] [HelpURL("https://docs.visualpinball.org/creators-guide/manual/mechanisms/magnets.html")] - public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable, IKinematicTransformComponent + public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable, IKinematicTransformComponent, IAnimationValueEmitter { public const string MotorCoilItem = "motor_coil"; public const string DirectionCoilItem = "direction_coil"; @@ -76,6 +77,11 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable private PhysicsEngine _physicsEngine; private Transform _rotationTarget; private Quaternion _rotationTargetInitialRotation; + private float2 _animationValue; + + internal float PublishedSpeed => _animationValue.x; + internal float PublishedRotationAngle => _animationValue.y; + public event Action OnAnimationValueChanged; public IEnumerable AvailableCoils => new[] { new GamelogicEngineCoil(MotorCoilItem) { @@ -175,7 +181,16 @@ private void Update() if (!_rotationTarget) { return; } - _rotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(TurntableApi.RotationAngle, Vector3.up); + _rotationTarget.localRotation = _rotationTargetInitialRotation * Quaternion.AngleAxis(PublishedRotationAngle, Vector3.up); + } + + public void UpdateAnimationValue(float2 value) + { + if (_animationValue.Equals(value)) { + return; + } + _animationValue = value; + OnAnimationValueChanged?.Invoke(value); } internal TurntableState CreateState() From 7b84840d3b18624b0e159c1aa6e97e96dd1c0ed0 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:37:38 +0200 Subject: [PATCH 47/50] physics(turntables): use the shared step duration Derive turntable acceleration and visual rotation timing from PhysicsConstants.DefaultStepTimeS instead of a magnet-owned update constant. The calculation still uses the VPX ten-millisecond time unit, but its ownership now matches the engine clock. This removes an unrelated subsystem dependency and prevents a future magnet cadence adjustment from silently retiming turntables. --- .../VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index 356c67a80..7e3d64679 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -16,6 +16,7 @@ using Unity.Burst; using Unity.Mathematics; +using VisualPinball.Engine.Common; namespace VisualPinball.Unity { @@ -23,7 +24,7 @@ namespace VisualPinball.Unity internal static class TurntablePhysics { private const float VpxTurntableForceDivisor = 8000f; - private const float SecondsPerVpxUpdate = MagnetPhysics.VpxMagnetUpdateMs * 0.001f; + private const float SecondsPerVpxUpdate = PhysicsConstants.DefaultStepTimeS; [BurstCompile] internal static void Update(int itemId, ref TurntableState turntable, ref PhysicsState state, float physicsDiffTime) From 4ba32666877e989a0d82814b29f5f210a29e910d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 00:38:35 +0200 Subject: [PATCH 48/50] editor(magnets): remove the VBS detector Remove the script-scanning editor window instead of extending its partial VBScript parser. The heuristic could not preserve general statement syntax, runtime assignments, custom helpers, or the final configured magnet state without effectively becoming a language interpreter. Drop the menu entry, source metadata, and creator documentation that advertised automatic detection. Native Magnet and Turntable components remain available for explicit setup and coil mapping. --- .../manual/mechanisms/magnets.md | 6 - .../VPT/Magnet/MagnetDetectionWindow.cs | 637 ------------------ .../VPT/Magnet/MagnetDetectionWindow.cs.meta | 11 - 3 files changed, 654 deletions(-) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index 4f4b3826e..0aadd1629 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -94,9 +94,3 @@ The turntable ramps toward **Max Speed** using **Spin Up**, then ramps back towa Enable **Is Kinematic** when a magnet or turntable is parented under a moving transform. The physics engine tracks the transform during gameplay, so the force field follows the moving center. A held ball is dragged along by the hold force — planar for playfield magnets, full 3-D for spatial magnets. Kinematic tracking follows the transform position and height. The magnetic field remains playfield-aligned for playfield magnets; tilted field axes are not modeled. Spatial magnets are the supported path for carrying a held ball away from the playfield. - -## Importing VPX Magnets - -Use *Pinball -> Tools -> Detect Magnets* to scan a VPX script after import. The tool looks for `cvpmMagnet` and `cvpmTurnTable` declarations, matches their trigger references, creates VPE components, and adds coil mappings when the script uses direct numeric solenoid callbacks. - -Review every detected item before creating components. Script expressions, custom helper classes, or nonstandard callback indirection may need manual coil mapping after detection. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs deleted file mode 100644 index ee22ad3fa..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs +++ /dev/null @@ -1,637 +0,0 @@ -// 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 System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Unity.Mathematics; -using UnityEditor; -using UnityEditor.SceneManagement; -using UnityEngine; -using VisualPinball.Engine.Math; -using Object = UnityEngine.Object; - -namespace VisualPinball.Unity.Editor -{ - public class MagnetDetectionWindow : EditorWindow - { - private const string WindowTitle = "Detect Magnets"; - private static readonly Regex NewDeviceRegex = new(@"^\s*Set\s+(?[A-Za-z_]\w*)\s*=\s*New\s+(?cvpmMagnet|cvpmTurntable)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex WithRegex = new(@"^\s*With\s+(?[A-Za-z_]\w*)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex EndWithRegex = new(@"^\s*End\s+With\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex InitMagnetRegex = new(@"(?[A-Za-z_]\w*)\.InitMagnet\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?|[A-Za-z_]\w*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex InitTurntableRegex = new(@"(?[A-Za-z_]\w*)\.InitTurnTable\s+(?[A-Za-z_]\w*)\s*,\s*(?[-+]?\d+(?:\.\d+)?|[A-Za-z_]\w*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex GrabCenterRegex = new(@"(?[A-Za-z_]\w*)\.GrabCenter\s*=\s*(?True|False|0|1)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex SolenoidRegex = new(@"(?[A-Za-z_]\w*)\.Solenoid\s*=\s*(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex SolCallbackRegex = new(@"SolCallback\s*\(\s*(?[^)]+)\s*\)\s*=\s*""(?[A-Za-z_]\w*)\.(?MagnetOn|MotorOn|SpinCW)\s*=", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CreateEventsRegex = new(@"(?[A-Za-z_]\w*)\.CreateEvents\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex BallTrackingRegex = new(@"(?[A-Za-z_]\w*)\.(AddBall|RemoveBall)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex StrengthAssignmentRegex = new(@"(?[A-Za-z_]\w*)\.Strength\s*=", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - [SerializeField] private TableComponent _tableComponent; - [SerializeField] private Object _scriptAsset; - [SerializeField] private string _scriptPath = string.Empty; - - private readonly List _candidates = new(); - private Vector2 _scroll; - private string _status = string.Empty; - - [MenuItem("Pinball/Tools/Detect Magnets", false, 412)] - public static void ShowWindow() - { - var window = GetWindow(); - window.titleContent = new GUIContent(WindowTitle); - window.TryUseSelectedTable(); - window.Show(); - } - - private void OnEnable() - { - titleContent = new GUIContent(WindowTitle); - if (!_tableComponent) { - TryUseSelectedTable(); - } - } - - private void OnGUI() - { - EditorGUILayout.Space(6f); - _tableComponent = (TableComponent)EditorGUILayout.ObjectField("Table", _tableComponent, typeof(TableComponent), true); - _scriptAsset = EditorGUILayout.ObjectField("Script Asset", _scriptAsset, typeof(Object), false); - - using (new EditorGUILayout.HorizontalScope()) { - _scriptPath = EditorGUILayout.TextField("Script Path", _scriptPath); - if (GUILayout.Button("Browse", GUILayout.Width(72))) { - var path = EditorUtility.OpenFilePanel("Select VPX Table Script", Application.dataPath, "vbs"); - if (!string.IsNullOrEmpty(path)) { - _scriptPath = path; - _scriptAsset = null; - } - } - } - - using (new EditorGUILayout.HorizontalScope()) { - if (GUILayout.Button("Use Selection", GUILayout.Width(110))) { - TryUseSelectedTable(); - } - if (GUILayout.Button("Scan", GUILayout.Width(80))) { - Scan(); - } - using (new EditorGUI.DisabledScope(_candidates.All(c => !c.Selected || !c.CanCreate))) { - if (GUILayout.Button("Create Selected", GUILayout.Width(120))) { - CreateSelected(); - } - } - } - - if (!string.IsNullOrEmpty(_status)) { - EditorGUILayout.HelpBox(_status, MessageType.Info); - } - - EditorGUILayout.Space(6f); - _scroll = EditorGUILayout.BeginScrollView(_scroll); - foreach (var candidate in _candidates) { - DrawCandidate(candidate); - } - EditorGUILayout.EndScrollView(); - } - - private void DrawCandidate(DetectionCandidate candidate) - { - using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { - using (new EditorGUILayout.HorizontalScope()) { - candidate.Selected = EditorGUILayout.Toggle(candidate.Selected, GUILayout.Width(18)); - EditorGUILayout.LabelField($"{candidate.TypeLabel}: {candidate.Name}", EditorStyles.boldLabel); - EditorGUILayout.LabelField($"line {candidate.Line}", GUILayout.Width(62)); - } - - EditorGUILayout.LabelField("Trigger", candidate.TriggerName); - EditorGUILayout.LabelField("Strength", candidate.Strength.ToString(CultureInfo.InvariantCulture)); - EditorGUILayout.LabelField("Grab", candidate.GrabBall ? "Yes" : "No"); - if (candidate.Kind == DetectionKind.Magnet) { - EditorGUILayout.LabelField("Coil", string.IsNullOrEmpty(candidate.CoilId) ? "None" : candidate.CoilId); - } else { - EditorGUILayout.LabelField("Motor Coil", string.IsNullOrEmpty(candidate.MotorCoilId) ? "None" : candidate.MotorCoilId); - EditorGUILayout.LabelField("Direction Coil", string.IsNullOrEmpty(candidate.DirectionCoilId) ? "None" : candidate.DirectionCoilId); - } - - foreach (var note in candidate.Notes) { - EditorGUILayout.HelpBox(note, MessageType.Warning); - } - if (!candidate.CanCreate) { - EditorGUILayout.HelpBox(candidate.BlockReason, MessageType.Info); - } - } - } - - private void Scan() - { - _candidates.Clear(); - _status = string.Empty; - - if (!_tableComponent) { - _status = "Select a table before scanning."; - return; - } - - if (!TryReadScript(out var script, out var error)) { - _status = error; - return; - } - - _candidates.AddRange(Parse(script)); - foreach (var candidate in _candidates) { - ResolveCandidate(candidate); - } - _status = _candidates.Count == 0 - ? "No cvpmMagnet or cvpmTurntable instances found." - : $"Found {_candidates.Count} magnet-related script candidate(s)."; - } - - private void ResolveCandidate(DetectionCandidate candidate) - { - if (candidate.Kind == DetectionKind.Turntable) { - candidate.Trigger = FindTrigger(candidate.TriggerName); - if (!candidate.Trigger) { - candidate.Selected = false; - candidate.BlockReason = $"Trigger \"{candidate.TriggerName}\" was not found in the selected table."; - } - return; - } - - candidate.Trigger = FindTrigger(candidate.TriggerName); - if (!candidate.Trigger) { - candidate.Selected = false; - candidate.BlockReason = $"Trigger \"{candidate.TriggerName}\" was not found in the selected table."; - } - } - - private void CreateSelected() - { - var created = new List(); - Undo.IncrementCurrentGroup(); - Undo.SetCurrentGroupName("Create detected magnets"); - var undoGroup = Undo.GetCurrentGroup(); - Undo.RecordObject(_tableComponent, "Create detected magnets"); - - foreach (var candidate in _candidates.Where(c => c.Selected && c.CanCreate)) { - var createdObject = candidate.Kind == DetectionKind.Turntable - ? CreateTurntable(candidate)?.gameObject - : CreateMagnet(candidate)?.gameObject; - if (createdObject) { - created.Add(createdObject); - } - } - - EditorUtility.SetDirty(_tableComponent); - EditorSceneManager.MarkSceneDirty(_tableComponent.gameObject.scene); - Undo.CollapseUndoOperations(undoGroup); - - if (created.Count > 0) { - Selection.objects = created.ToArray(); - } - _status = $"Created {created.Count} magnet(s)."; - } - - private MagnetComponent CreateMagnet(DetectionCandidate candidate) - { - var trigger = candidate.Trigger; - if (!trigger) { - return null; - } - - var parent = trigger.transform.parent; - var name = GameObjectUtility.GetUniqueNameForSibling(parent, candidate.Name); - var go = new GameObject(name); - Undo.RegisterCreatedObjectUndo(go, "Create detected magnet"); - go.transform.SetParent(parent, false); - go.transform.localPosition = trigger.transform.localPosition; - go.transform.localRotation = trigger.transform.localRotation; - go.transform.localScale = Vector3.one; - - var magnet = Undo.AddComponent(go); - Undo.RecordObject(magnet, "Configure detected magnet"); - magnet.Radius = VpxRadiusToMillimeters(GetTriggerRadius(trigger)); - magnet.Strength = candidate.Strength; - magnet.ForceProfile = MagnetForceProfile.VpxCompatible; - magnet.GrabBall = candidate.GrabBall; - magnet.IsEnabledOnStart = false; - - if (!string.IsNullOrEmpty(candidate.CoilId) && !HasCoilMapping(candidate.CoilId, magnet)) { - _tableComponent.MappingConfig.AddCoil(new CoilMapping { - Id = candidate.CoilId, - Description = $"{candidate.Name} magnet", - Destination = CoilDestination.Playfield, - Device = magnet, - DeviceItem = MagnetComponent.MagnetCoilItem - }); - } - - EditorUtility.SetDirty(magnet); - return magnet; - } - - private TurntableComponent CreateTurntable(DetectionCandidate candidate) - { - var trigger = candidate.Trigger; - if (!trigger) { - return null; - } - - var parent = trigger.transform.parent; - var name = GameObjectUtility.GetUniqueNameForSibling(parent, candidate.Name); - var go = new GameObject(name); - Undo.RegisterCreatedObjectUndo(go, "Create detected turntable"); - go.transform.SetParent(parent, false); - go.transform.localPosition = trigger.transform.localPosition; - go.transform.localRotation = trigger.transform.localRotation; - go.transform.localScale = Vector3.one; - - var turntable = Undo.AddComponent(go); - Undo.RecordObject(turntable, "Configure detected turntable"); - turntable.Radius = VpxRadiusToMillimeters(GetTriggerRadius(trigger)); - turntable.MaxSpeed = candidate.Strength; - turntable.MotorOnStart = false; - turntable.SpinClockwise = true; - - AddTurntableCoilMapping(candidate.MotorCoilId, turntable, TurntableComponent.MotorCoilItem, $"{candidate.Name} motor"); - AddTurntableCoilMapping(candidate.DirectionCoilId, turntable, TurntableComponent.DirectionCoilItem, $"{candidate.Name} direction"); - - EditorUtility.SetDirty(turntable); - return turntable; - } - - private void AddTurntableCoilMapping(string coilId, TurntableComponent turntable, string deviceItem, string description) - { - if (string.IsNullOrEmpty(coilId) || HasCoilMapping(coilId, turntable, deviceItem)) { - return; - } - _tableComponent.MappingConfig.AddCoil(new CoilMapping { - Id = coilId, - Description = description, - Destination = CoilDestination.Playfield, - Device = turntable, - DeviceItem = deviceItem - }); - } - - private bool HasCoilMapping(string coilId, MagnetComponent magnet) - { - return _tableComponent.MappingConfig.Coils.Any(mapping => - string.Equals(mapping.Id, coilId, StringComparison.OrdinalIgnoreCase) && - mapping.Device is MagnetComponent mappedMagnet && - mappedMagnet == magnet && - mapping.DeviceItem == MagnetComponent.MagnetCoilItem); - } - - private bool HasCoilMapping(string coilId, TurntableComponent turntable, string deviceItem) - { - return _tableComponent.MappingConfig.Coils.Any(mapping => - string.Equals(mapping.Id, coilId, StringComparison.OrdinalIgnoreCase) && - mapping.Device is TurntableComponent mappedTurntable && - mappedTurntable == turntable && - mapping.DeviceItem == deviceItem); - } - - private TriggerComponent FindTrigger(string triggerName) - { - return _tableComponent.GetComponentsInChildren(true) - .FirstOrDefault(trigger => string.Equals(trigger.name, triggerName, StringComparison.OrdinalIgnoreCase)); - } - - private static float GetTriggerRadius(TriggerComponent trigger) - { - var collider = trigger.GetComponentInChildren(true); - if (collider && collider.HitCircleRadius > 0f) { - return collider.HitCircleRadius; - } - - var maxRadius = 25f; - foreach (var dragPoint in trigger.DragPoints ?? Array.Empty()) { - maxRadius = math.max(maxRadius, math.length(new float2(dragPoint.Center.X, dragPoint.Center.Y))); - } - return maxRadius; - } - - private static float VpxRadiusToMillimeters(float radiusVpx) - => VisualPinball.Unity.Physics.ScaleToWorld(radiusVpx) / MagnetComponent.MillimetersToWorld; - - private bool TryReadScript(out string script, out string error) - { - var path = ResolveScriptPath(); - if (string.IsNullOrEmpty(path)) { - script = string.Empty; - error = "Select or browse to a VPX table script."; - return false; - } - if (!File.Exists(path)) { - script = string.Empty; - error = $"Script file does not exist: {path}"; - return false; - } - script = File.ReadAllText(path); - error = string.Empty; - return true; - } - - private string ResolveScriptPath() - { - if (_scriptAsset) { - var assetPath = AssetDatabase.GetAssetPath(_scriptAsset); - if (!string.IsNullOrEmpty(assetPath)) { - _scriptPath = Path.GetFullPath(assetPath); - } - } - return _scriptPath; - } - - private void TryUseSelectedTable() - { - if (!Selection.activeGameObject) { - return; - } - _tableComponent = Selection.activeGameObject.GetComponentInParent(); - } - - private static IEnumerable Parse(string script) - { - var devices = new Dictionary(StringComparer.OrdinalIgnoreCase); - string withTarget = null; - var lines = script.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); - - for (var i = 0; i < lines.Length; i++) { - var lineNumber = i + 1; - var line = StripComment(lines[i]).Trim(); - if (line.Length == 0) { - continue; - } - - var withMatch = WithRegex.Match(line); - if (withMatch.Success) { - withTarget = withMatch.Groups["name"].Value; - continue; - } - if (EndWithRegex.IsMatch(line)) { - withTarget = null; - continue; - } - if (withTarget != null && line.StartsWith(".", StringComparison.Ordinal)) { - line = withTarget + line; - } - - ReadNewDevice(line, lineNumber, devices); - ReadInit(line, lineNumber, devices); - ReadGrab(line, devices); - ReadSolenoid(line, lineNumber, devices); - ReadCreateEvents(line, devices); - ReadBallTracking(line, lineNumber, devices); - ReadStrengthMutation(line, lineNumber, devices); - } - - return devices.Values - .Where(device => device.HasInit) - .Select(device => device.ToCandidate()) - .OrderBy(candidate => candidate.Line) - .ToList(); - } - - private static void ReadNewDevice(string line, int lineNumber, Dictionary devices) - { - var match = NewDeviceRegex.Match(line); - if (!match.Success) { - return; - } - var device = GetDevice(devices, match.Groups["name"].Value); - device.Kind = ParseKind(match.Groups["type"].Value); - device.Line = lineNumber; - } - - private static void ReadInit(string line, int lineNumber, Dictionary devices) - { - var magnet = InitMagnetRegex.Match(line); - if (magnet.Success) { - var device = GetDevice(devices, magnet.Groups["name"].Value); - device.Kind = DetectionKind.Magnet; - device.TriggerName = magnet.Groups["trigger"].Value; - device.Strength = ParseStrength(device, magnet.Groups["strength"].Value, 10f, lineNumber); - device.Line = device.Line == 0 ? lineNumber : device.Line; - device.HasInit = true; - return; - } - - var turntable = InitTurntableRegex.Match(line); - if (turntable.Success) { - var device = GetDevice(devices, turntable.Groups["name"].Value); - device.Kind = DetectionKind.Turntable; - device.TriggerName = turntable.Groups["trigger"].Value; - device.Strength = ParseStrength(device, turntable.Groups["strength"].Value, 100f, lineNumber); - device.Line = device.Line == 0 ? lineNumber : device.Line; - device.HasInit = true; - } - } - - private static void ReadGrab(string line, Dictionary devices) - { - var match = GrabCenterRegex.Match(line); - if (match.Success) { - var device = GetDevice(devices, match.Groups["name"].Value); - device.GrabCenter = IsTruthy(match.Groups["value"].Value); - device.HasExplicitGrab = true; - } - } - - private static void ReadSolenoid(string line, int lineNumber, Dictionary devices) - { - var solenoid = SolenoidRegex.Match(line); - if (solenoid.Success) { - var device = GetDevice(devices, solenoid.Groups["name"].Value); - device.CoilId = solenoid.Groups["coil"].Value; - return; - } - - var callback = SolCallbackRegex.Match(line); - if (!callback.Success) { - return; - } - - var callbackDevice = GetDevice(devices, callback.Groups["name"].Value); - var coil = callback.Groups["coil"].Value.Trim(); - if (int.TryParse(coil, NumberStyles.Integer, CultureInfo.InvariantCulture, out var coilId)) { - callbackDevice.AssignCallbackCoil(callback.Groups["member"].Value, coilId.ToString(CultureInfo.InvariantCulture)); - } else { - callbackDevice.Notes.Add($"Line {lineNumber}: solenoid callback uses expression \"{coil}\"; map the coil manually."); - } - } - - private static void ReadCreateEvents(string line, Dictionary devices) - { - var match = CreateEventsRegex.Match(line); - if (match.Success) { - GetDevice(devices, match.Groups["name"].Value).HasCreateEvents = true; - } - } - - private static void ReadBallTracking(string line, int lineNumber, Dictionary devices) - { - var match = BallTrackingRegex.Match(line); - if (match.Success) { - GetDevice(devices, match.Groups["name"].Value).ManualBallLines.Add(lineNumber); - } - } - - private static void ReadStrengthMutation(string line, int lineNumber, Dictionary devices) - { - var match = StrengthAssignmentRegex.Match(line); - if (match.Success && line.IndexOf("InitMagnet", StringComparison.OrdinalIgnoreCase) < 0) { - GetDevice(devices, match.Groups["name"].Value).StrengthMutationLines.Add(lineNumber); - } - } - - private static ParsedDevice GetDevice(Dictionary devices, string name) - { - if (!devices.TryGetValue(name, out var device)) { - device = new ParsedDevice { Name = name }; - devices.Add(name, device); - } - return device; - } - - private static DetectionKind ParseKind(string type) - => type.Equals("cvpmTurntable", StringComparison.OrdinalIgnoreCase) ? DetectionKind.Turntable : DetectionKind.Magnet; - - /// - /// Parses a numeric init value; scripts often pass a named constant instead - /// (e.g. `.InitMagnet MagnaSave, kOrbMagnetPower`), which cannot be resolved - /// here — those get the fallback plus a manual-review note. - /// - private static float ParseStrength(ParsedDevice device, string value, float fallback, int lineNumber) - { - if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { - return result; - } - device.Notes.Add($"Line {lineNumber}: init value is the script expression \"{value}\"; created with default {fallback.ToString(CultureInfo.InvariantCulture)}, adjust manually."); - return fallback; - } - - private static bool IsTruthy(string value) - => value.Equals("True", StringComparison.OrdinalIgnoreCase) || value == "1"; - - private static string StripComment(string line) - { - var inString = false; - for (var i = 0; i < line.Length; i++) { - if (line[i] == '"') { - inString = !inString; - } else if (line[i] == '\'' && !inString) { - return line.Substring(0, i); - } - } - return line; - } - - private enum DetectionKind - { - Magnet, - Turntable - } - - private sealed class ParsedDevice - { - public string Name; - public DetectionKind Kind; - public string TriggerName = string.Empty; - public float Strength; - public int Line; - public bool HasInit; - public bool HasCreateEvents; - public bool HasExplicitGrab; - public bool GrabCenter; - public string CoilId = string.Empty; - public string MotorCoilId = string.Empty; - public string DirectionCoilId = string.Empty; - public readonly List ManualBallLines = new(); - public readonly List StrengthMutationLines = new(); - public readonly List Notes = new(); - - public void AssignCallbackCoil(string member, string coilId) - { - if (member.Equals("MotorOn", StringComparison.OrdinalIgnoreCase)) { - MotorCoilId = coilId; - } else if (member.Equals("SpinCW", StringComparison.OrdinalIgnoreCase)) { - DirectionCoilId = coilId; - } else { - CoilId = coilId; - } - } - - public DetectionCandidate ToCandidate() - { - var candidate = new DetectionCandidate { - Name = Name, - Kind = Kind, - TriggerName = TriggerName, - Strength = Strength, - Line = Line, - GrabBall = Kind == DetectionKind.Magnet && (HasExplicitGrab ? GrabCenter : Strength > 14f), - CoilId = CoilId, - MotorCoilId = MotorCoilId, - DirectionCoilId = DirectionCoilId - }; - candidate.Notes.AddRange(Notes); - if (ManualBallLines.Count > 0 && !HasCreateEvents) { - candidate.Notes.Add($"Manual AddBall/RemoveBall calls at line(s) {string.Join(", ", ManualBallLines)}; verify membership behavior."); - } - if (StrengthMutationLines.Count > 0) { - candidate.Notes.Add($"Runtime Strength assignment at line(s) {string.Join(", ", StrengthMutationLines)}; port the script-side modulation."); - } - if (Kind == DetectionKind.Magnet && string.IsNullOrEmpty(CoilId)) { - candidate.Notes.Add("No solenoid mapping found."); - } else if (Kind == DetectionKind.Turntable) { - if (string.IsNullOrEmpty(MotorCoilId)) { - candidate.Notes.Add("No motor solenoid mapping found."); - } - if (string.IsNullOrEmpty(DirectionCoilId)) { - candidate.Notes.Add("No direction solenoid mapping found."); - } - } - return candidate; - } - } - - private sealed class DetectionCandidate - { - public bool Selected = true; - public string Name; - public DetectionKind Kind; - public string TriggerName; - public float Strength; - public bool GrabBall; - public string CoilId; - public string MotorCoilId; - public string DirectionCoilId; - public int Line; - public TriggerComponent Trigger; - public string BlockReason = string.Empty; - public readonly List Notes = new(); - - public bool CanCreate => Trigger && (Kind == DetectionKind.Magnet || Kind == DetectionKind.Turntable); - public string TypeLabel => Kind == DetectionKind.Magnet ? "Magnet" : "Turntable"; - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta deleted file mode 100644 index f40ad9964..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetDetectionWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 3337cbede1b743699201e6d49faf8807 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: From 92742a9bc99d3a79a607cf8ce9e05346909bb9f1 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 19:57:42 +0200 Subject: [PATCH 49/50] physics(turntables): select spin ramp by motor state The ramp was chosen by comparing speed magnitudes, which made a driven direction reversal briefly coast on the friction ramp. cvpmTurnTable semantics are simpler: the motor ramp applies whenever the motor is on, the friction ramp when it coasts down. --- .../Physics/TurntablePhysicsTests.cs | 36 +++++++++++++++++++ .../VPT/Turntable/TurntablePhysics.cs | 5 ++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs index 88e27df93..9839dec86 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/TurntablePhysicsTests.cs @@ -77,6 +77,42 @@ public void SpeedRampsTowardMotorTarget() Assert.That(turntable.Speed, Is.EqualTo(1f).Within(1e-5f)); } + [Test] + public void MotorOffCoastsDownWithSpinDown() + { + var turntable = new TurntableState { + Speed = 50f, + MaxSpeed = 100f, + TargetSpeed = 100f, + SpinUp = 100f, + SpinDown = 4f, + MotorOn = false + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(50f - 4f * 0.01f).Within(1e-5f)); + } + + [Test] + public void DirectionReversalRampsWithSpinUp() + { + // the motor drives the reversal all the way through, so the motor ramp + // applies while decelerating toward zero as well + var turntable = new TurntableState { + Speed = 100f, + MaxSpeed = 100f, + TargetSpeed = -100f, + SpinUp = 10f, + SpinDown = 4f, + MotorOn = true + }; + + TurntablePhysics.UpdateSpeed(ref turntable, 1f); + + Assert.That(turntable.Speed, Is.EqualTo(100f - 10f * 0.01f).Within(1e-5f)); + } + [Test] public void KinematicTransformUpdatesTurntableCenterAndHeight() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index 7e3d64679..2e45727be 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -65,8 +65,11 @@ internal static void ApplyKinematicTransform(ref TurntableState turntable, in fl internal static void UpdateSpeed(ref TurntableState turntable, float physicsDiffTime) { + // cvpmTurnTable semantics: the motor ramp applies whenever the motor drives + // the disc — including through a direction reversal — and the friction ramp + // applies when it coasts down var targetSpeed = turntable.MotorOn ? turntable.TargetSpeed : 0f; - var acceleration = math.abs(targetSpeed) > math.abs(turntable.Speed) ? turntable.SpinUp : turntable.SpinDown; + var acceleration = turntable.MotorOn ? turntable.SpinUp : turntable.SpinDown; turntable.Speed = MoveTowards(turntable.Speed, targetSpeed, math.max(0f, acceleration) * physicsDiffTime * SecondsPerVpxUpdate); } From d2fd8726090f54849710692e72f813f81cbfc28e Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 19:57:42 +0200 Subject: [PATCH 50/50] magnets: document zero height range and tidy runtime state A height range of zero means unlimited vertical reach, which the tooltips and manual now state. Also drop a dead clamp on the constant planar damping and mark the coil enabled flag volatile, since the simulation thread writes it while the main thread reads it. --- .../creators-guide/manual/mechanisms/magnets.md | 4 ++-- .../VisualPinball.Unity/VPT/Magnet/MagnetApi.cs | 3 ++- .../VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs | 4 ++-- .../VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md index 0aadd1629..4c5014601 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/magnets.md @@ -20,7 +20,7 @@ The selected object shows a radius gizmo in the scene view. Playfield magnets dr |---|---| | **Magnet Type** | **Playfield** for under-playfield magnets with a cylindrical range, **Spatial** for mech-mounted magnets that grab and carry balls in 3-D. | | **Radius** | Influence radius in millimeters. Playfield magnets use a planar radius; spatial magnets use a spherical radius. | -| **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. Spatial magnets ignore this field. | +| **Height Range** | Vertical window above a playfield magnet. Use this to avoid affecting balls on ramps above the playfield. A value of zero disables the vertical limit. Spatial magnets ignore this field. | | **Strength** | Full-power magnet force. In VPX Compatible mode, this uses familiar `cvpmMagnet` strength values and negative values repel. | | **Force Profile** | **VPX Compatible** for imported VPX behavior, **Physical** for new VPE tables that use the finite-pole field model. Spatial magnets always use physical 3-D force semantics. | | **Pole Radius** | Effective radius of the circular or annular pole face. The Physical force is strongest in a ring near this pole and is zero laterally at the exact center. | @@ -78,7 +78,7 @@ Because the hold is a force rather than a rigid lock, a hard enough hit from ano ## Turntable Setup -Add the **Turntable** component with *Add Component -> Pinball -> Mechs -> Turntable*. Place it at the disc center and set **Radius** to cover the area where the spinning disc should affect the ball. +Add the **Turntable** component with *Add Component -> Pinball -> Mechs -> Turntable*. Place it at the disc center and set **Radius** to cover the area where the spinning disc should affect the ball. **Height Range** bounds the effect vertically so balls on ramps above the disc are unaffected; a value of zero disables the vertical limit. Turntables expose two coils: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 12930340e..f0ae2fe09 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -31,7 +31,8 @@ public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceD private DeviceCoil _magnetCoil; private DeviceSwitch _ballHeldSwitch; - private bool _isEnabled; + // written by the simulation thread's coil dispatch, read by the main thread + private volatile bool _isEnabled; private readonly HashSet _heldBalls = new(); public event EventHandler Init; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 3c9d61999..27032f3aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -78,7 +78,7 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Min(0f)] [Unit("mm")] - [Tooltip("Vertical range above the magnet surface where balls are affected.")] + [Tooltip("Vertical range above the magnet surface where balls are affected. Zero means unlimited.")] public float HeightRange = 50f; [Tooltip("Whether the magnet starts enabled before coil or script control changes it.")] @@ -169,7 +169,7 @@ internal MagnetState CreateState() FallTime = CoilFallTime / MagnetPhysics.VpxMagnetUpdateMs, PoleRadius = MillimetersToVpx(PoleRadius), GrabRadius = GrabBall ? MillimetersToVpx(GrabRadius) : 0f, - PlanarDamping = math.clamp(DefaultPlanarDamping, 0f, 1f), + PlanarDamping = DefaultPlanarDamping, IsEnabled = IsEnabledOnStart, IsKinematic = IsKinematic, // spatial magnets dispatch on MagnetType and never read Profile diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs index 5c8589394..31a2718b8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntableComponent.cs @@ -42,7 +42,7 @@ public class TurntableComponent : MonoBehaviour, ICoilDeviceComponent, IPackable [Min(0f)] [Unit("mm")] - [Tooltip("Vertical range above the disc surface where balls are affected.")] + [Tooltip("Vertical range above the disc surface where balls are affected. Zero means unlimited.")] public float HeightRange = 50f; [Tooltip("VPX-compatible maximum turntable speed.")]