diff --git a/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx b/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx new file mode 100644 index 000000000..2608128b1 Binary files /dev/null and b/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx differ diff --git a/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx.meta b/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx.meta new file mode 100644 index 000000000..5761f426b --- /dev/null +++ b/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx.meta @@ -0,0 +1,114 @@ +fileFormatVersion: 2 +guid: d78bc7cfea69c684c8c631e82dd9e206 +ModelImporter: + serializedVersion: 24501 + internalIDToNameTable: [] + externalObjects: {} + materials: + materialImportMode: 2 + materialName: 0 + materialSearch: 1 + materialLocation: 1 + searchTexturesGlobally: 0 + animations: + legacyGenerateAnimations: 4 + bakeSimulation: 0 + resampleCurves: 1 + optimizeGameObjects: 0 + removeConstantScaleCurves: 1 + motionNodeName: + animationImportErrors: + animationImportWarnings: + animationRetargetingWarnings: + animationDoRetargetingWarnings: 0 + importAnimatedCustomProperties: 0 + importConstraints: 0 + animationCompression: 1 + animationRotationError: 0.5 + animationPositionError: 0.5 + animationScaleError: 0.5 + animationWrapMode: 0 + extraExposedTransformPaths: [] + extraUserProperties: [] + clipAnimations: [] + isReadable: 1 + meshes: + lODScreenPercentages: [] + globalScale: 1 + meshCompression: 0 + addColliders: 0 + useSRGBMaterialColor: 1 + sortHierarchyByName: 1 + importPhysicalCameras: 1 + importVisibility: 1 + importBlendShapes: 1 + importCameras: 1 + importLights: 1 + nodeNameCollisionStrategy: 1 + fileIdsGeneration: 2 + swapUVChannels: 0 + generateSecondaryUV: 0 + useFileUnits: 1 + keepQuads: 0 + weldVertices: 1 + bakeAxisConversion: 0 + preserveHierarchy: 0 + skinWeightsMode: 0 + maxBonesPerVertex: 4 + minBoneWeight: 0.001 + optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 + importUVs: -1 + importVertexColors: 1 + meshOptimizationFlags: -1 + indexFormat: 0 + secondaryUVAngleDistortion: 8 + secondaryUVAreaDistortion: 15.000001 + secondaryUVHardAngle: 88 + secondaryUVMarginMethod: 1 + secondaryUVMinLightmapResolution: 40 + secondaryUVMinObjectScale: 1 + secondaryUVPackMargin: 4 + useFileScale: 1 + strictVertexDataChecks: 0 + tangentSpace: + normalSmoothAngle: 60 + normalImportMode: 0 + tangentImportMode: 3 + normalCalculationMode: 4 + legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 + blendShapeNormalImportMode: 1 + normalSmoothingSource: 0 + calculateBlendshapeNormalsDeltaFromImportedNormals: 0 + referencedClips: [] + importAnimation: 1 + humanDescription: + serializedVersion: 3 + human: [] + skeleton: [] + armTwist: 0.5 + foreArmTwist: 0.5 + upperLegTwist: 0.5 + legTwist: 0.5 + armStretch: 0.05 + legStretch: 0.05 + feetSpacing: 0 + globalScale: 1 + rootMotionBoneName: + hasTranslationDoF: 0 + hasExtraRoot: 0 + skeletonHasParents: 1 + lastHumanDescriptionAvatarSource: {instanceID: 0} + autoGenerateAvatarMappingIfUnspecified: 1 + animationType: 2 + humanoidOversampling: 1 + avatarSetup: 0 + addHumanoidExtraRootOnlyWhenUsingAvatar: 1 + importBlendShapeDeformPercent: 1 + remapMaterialsIfMaterialImportModeIsNone: 0 + additionalBone: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab b/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab index a5cdcea82..e4a874cb8 100644 --- a/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab +++ b/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab @@ -41,7 +41,7 @@ MeshFilter: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8289283333368007096} - m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0} + m_Mesh: {fileID: 4983844940297228651, guid: d78bc7cfea69c684c8c631e82dd9e206, type: 3} --- !u!23 &5180081487853661404 MeshRenderer: m_ObjectHideFlags: 0 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/TroughInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/TroughInspector.cs index 9677f45c7..78909b5f3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/TroughInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Inspectors/TroughInspector.cs @@ -47,7 +47,8 @@ public class TroughInspector : MainInspector private SerializedProperty _playfieldEntrySwitchProperty; private SerializedProperty _playfieldExitKickerProperty; private SerializedProperty _ballCountProperty; - private SerializedProperty _ballProperty; + private SerializedProperty _legacyBallProperty; + private SerializedProperty _ballPrefabsProperty; private SerializedProperty _switchCountProperty; private SerializedProperty _jamSwitchProperty; private SerializedProperty _rollTimeProperty; @@ -61,8 +62,9 @@ protected override void OnEnable() _typeProperty = serializedObject.FindProperty(nameof(TroughComponent.Type)); _playfieldEntrySwitchProperty = serializedObject.FindProperty(nameof(TroughComponent._playfieldEntrySwitch)); _playfieldExitKickerProperty = serializedObject.FindProperty(nameof(TroughComponent.PlayfieldExitKicker)); - _ballProperty = serializedObject.FindProperty(nameof(TroughComponent.Ball)); + _legacyBallProperty = serializedObject.FindProperty(nameof(TroughComponent.Ball)); _ballCountProperty = serializedObject.FindProperty(nameof(TroughComponent.BallCount)); + _ballPrefabsProperty = serializedObject.FindProperty(nameof(TroughComponent.BallPrefabs)); _switchCountProperty = serializedObject.FindProperty(nameof(TroughComponent.SwitchCount)); _jamSwitchProperty = serializedObject.FindProperty(nameof(TroughComponent.JamSwitch)); _rollTimeProperty = serializedObject.FindProperty(nameof(TroughComponent.RollTime)); @@ -80,15 +82,12 @@ public override void OnInspectorGUI() DropDownProperty("Type", _typeProperty, TypeLabels, TypeValues); - PropertyField(_ballProperty, "Ball Prefab"); - if (MainComponent.Ball && !MainComponent.Ball.GetComponent()) { - EditorGUILayout.HelpBox("Ball prefab must contain a ball component.", MessageType.Error); - } - - if (MainComponent.Type != TroughType.ClassicSingleBall) { + if (_typeProperty.intValue != TroughType.ClassicSingleBall) { PropertyField(_ballCountProperty); } + DrawBallPrefabs(); + switch (MainComponent.Type) { case TroughType.ModernOpto: case TroughType.ModernMech: @@ -172,6 +171,40 @@ public override void OnInspectorGUI() } } + private void DrawBallPrefabs() + { + if (_typeProperty.hasMultipleDifferentValues || + _typeProperty.intValue != TroughType.ClassicSingleBall && _ballCountProperty.hasMultipleDifferentValues) { + EditorGUILayout.HelpBox("Edit ball prefabs separately when selected troughs have different types or ball counts.", MessageType.Info); + return; + } + + var ballCount = _typeProperty.intValue == TroughType.ClassicSingleBall ? 1 : _ballCountProperty.intValue; + var previousSize = _ballPrefabsProperty.arraySize; + if (previousSize == 0 && _legacyBallProperty.hasMultipleDifferentValues) { + EditorGUILayout.HelpBox("Edit legacy ball prefabs separately before migrating this selection.", MessageType.Info); + return; + } + var migrateLegacyPrefab = previousSize == 0 && !_legacyBallProperty.hasMultipleDifferentValues + ? _legacyBallProperty.objectReferenceValue + : null; + _ballPrefabsProperty.arraySize = ballCount; + + for (var i = previousSize; i < ballCount; i++) { + _ballPrefabsProperty.GetArrayElementAtIndex(i).objectReferenceValue = migrateLegacyPrefab; + } + + for (var i = 0; i < ballCount; i++) { + var ballPrefabProperty = _ballPrefabsProperty.GetArrayElementAtIndex(i); + PropertyField(ballPrefabProperty, $"Ball {i + 1} Prefab"); + if (ballPrefabProperty.objectReferenceValue is GameObject ballPrefab && !ballPrefab.GetComponent()) { + EditorGUILayout.HelpBox($"Ball {i + 1} prefab must contain a ball component on its root.", MessageType.Error); + } + } + + EditorGUILayout.HelpBox("Leave a slot empty to use the current render pipeline's default ball.", MessageType.None); + } + private static void DrawSwitch(string label, DeviceSwitch sw) { var labelPos = EditorGUILayout.GetControlRect(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs new file mode 100644 index 000000000..0faafd756 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs @@ -0,0 +1,178 @@ +// 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. + +using System.Collections; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace VisualPinball.Unity.Test +{ + public class BallManagerPlayModeTests + { + private const string BallMeshPath = "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/Assets/Art/Meshes/Ball.fbx"; + private const string DefaultBallPath = "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/Assets/Resources/Prefabs/DefaultBall.prefab"; + + [UnityTearDown] + public IEnumerator LeavePlayModeAfterEachTest() + { + if (Application.isPlaying) { + yield return new ExitPlayMode(); + } + } + + [Test] + public void DefaultBallUsesCorrectlySizedUvMesh() + { + var uvMesh = AssetDatabase.LoadAllAssetsAtPath(BallMeshPath) + .OfType() + .Single(mesh => mesh.name == "Ball UV"); + var defaultBall = AssetDatabase.LoadAssetAtPath(DefaultBallPath); + + Assert.That(defaultBall, Is.Not.Null); + Assert.That(defaultBall.transform.localScale, Is.EqualTo(Vector3.one)); + Assert.That(defaultBall.GetComponent().sharedMesh, Is.SameAs(uvMesh)); + Assert.That(uvMesh.bounds.size.x, Is.EqualTo(Physics.ScaleToWorld(new Vector3(50f, 50f, 50f)).x).Within(0.000001f)); + } + + [UnityTest] + public IEnumerator ScalesRelativeToAuthoredPrefabAndTracksItsSource() + { + Assert.That(Application.isPlaying, Is.False); + yield return new EnterPlayMode(); + + var root = new GameObject("Ball Manager Play Mode Fixture"); + root.SetActive(false); + root.AddComponent(); + root.AddComponent(); + var player = root.AddComponent(); + var physicsEngine = root.AddComponent(); + + var playfieldObject = new GameObject("Playfield"); + playfieldObject.transform.SetParent(root.transform, false); + playfieldObject.AddComponent(); + + var prefabHolder = new GameObject("Prefab Holder"); + prefabHolder.transform.SetParent(root.transform, false); + prefabHolder.SetActive(false); + var ballPrefab = new GameObject("Large Authored Ball"); + ballPrefab.transform.SetParent(prefabHolder.transform, false); + ballPrefab.transform.localScale = Vector3.one * 2f; + ballPrefab.AddComponent().Radius = 50f; + + try { + root.SetActive(true); + yield return null; + yield return null; + + var ballId = player.BallManager.CreateBall(new DebugBallCreator(0f, 0f), 25f, 1f, ballPrefab); + Assert.That(physicsEngine.TryGetBall(ballId, out var ball), Is.True); + Assert.That(ball.transform.localScale, Is.EqualTo(Vector3.one)); + Assert.That(ball.SourcePrefab, Is.SameAs(ballPrefab)); + Assert.That(ball.Radius, Is.EqualTo(25f)); + } finally { + Object.DestroyImmediate(root); + } + + yield return new ExitPlayMode(); + } + + [UnityTest] + public IEnumerator TroughRequeuesThePrefabOfEachDrainedBall() + { + Assert.That(Application.isPlaying, Is.False); + yield return new EnterPlayMode(); + + var root = new GameObject("Trough Ball Queue Play Mode Fixture"); + root.SetActive(false); + root.AddComponent(); + root.AddComponent(); + var player = root.AddComponent(); + root.AddComponent(); + + var playfieldObject = new GameObject("Playfield"); + playfieldObject.transform.SetParent(root.transform, false); + playfieldObject.AddComponent(); + + var entryObject = new GameObject("Drain Trigger"); + entryObject.transform.SetParent(playfieldObject.transform, false); + var entry = entryObject.AddComponent(); + + var exitObject = new GameObject("Exit Kicker"); + exitObject.transform.SetParent(playfieldObject.transform, false); + var exit = exitObject.AddComponent(); + exit.Coils[0].Id = "exit"; + + var prefabHolder = new GameObject("Prefab Holder"); + prefabHolder.transform.SetParent(root.transform, false); + prefabHolder.SetActive(false); + var firstPrefab = CreateBallPrefab(prefabHolder.transform, "First Prefab"); + var secondPrefab = CreateBallPrefab(prefabHolder.transform, "Second Prefab"); + var thirdPrefab = CreateBallPrefab(prefabHolder.transform, "Third Prefab"); + var fourthPrefab = CreateBallPrefab(prefabHolder.transform, "Fourth Prefab"); + + var troughObject = new GameObject("Trough"); + troughObject.transform.SetParent(playfieldObject.transform, false); + var trough = troughObject.AddComponent(); + trough.Type = VisualPinball.Engine.VPT.TroughType.ModernMech; + trough.BallCount = 4; + trough.SwitchCount = 3; + trough.BallPrefabs = new[] { firstPrefab, secondPrefab, thirdPrefab, fourthPrefab }; + trough.PlayfieldEntrySwitch = entry; + trough.PlayfieldEntrySwitchItem = TriggerComponent.SwitchItem; + trough.PlayfieldExitKicker = exit; + trough.PlayfieldExitKickerItem = "exit"; + + try { + root.SetActive(true); + yield return null; + yield return null; + + Assert.That(trough.TroughApi.UncountedStackBalls, Is.EqualTo(1)); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + trough.TroughApi.StackSwitch(0).SetSwitch(true); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + var firstBall = playfieldObject.transform.Find("Ball 0").GetComponent(); + var secondBall = playfieldObject.transform.Find("Ball 1").GetComponent(); + Assert.That(firstBall.SourcePrefab, Is.SameAs(firstPrefab)); + Assert.That(secondBall.SourcePrefab, Is.SameAs(secondPrefab)); + + // Drain the second ejected ball first, then the first one. + ((IApiHittable)entry.TriggerApi).OnHit(secondBall.Id); + ((IApiHittable)entry.TriggerApi).OnHit(firstBall.Id); + + trough.TroughApi.StackSwitch(0).SetSwitch(true); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + trough.TroughApi.StackSwitch(0).SetSwitch(true); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + trough.TroughApi.StackSwitch(0).SetSwitch(true); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + trough.TroughApi.StackSwitch(0).SetSwitch(true); + Assert.That(trough.TroughApi.EjectBall(), Is.True); + Assert.That(playfieldObject.transform.Find("Ball 2").GetComponent().SourcePrefab, Is.SameAs(thirdPrefab)); + Assert.That(playfieldObject.transform.Find("Ball 3").GetComponent().SourcePrefab, Is.SameAs(fourthPrefab)); + Assert.That(playfieldObject.transform.Find("Ball 4").GetComponent().SourcePrefab, Is.SameAs(secondPrefab)); + Assert.That(playfieldObject.transform.Find("Ball 5").GetComponent().SourcePrefab, Is.SameAs(firstPrefab)); + } finally { + Object.DestroyImmediate(root); + } + + yield return new ExitPlayMode(); + } + + private static GameObject CreateBallPrefab(Transform parent, string name) + { + var prefab = new GameObject(name); + prefab.transform.SetParent(parent, false); + prefab.AddComponent(); + return prefab; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs.meta new file mode 100644 index 000000000..b589cf31f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/BallManagerPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ab5e78798ce838419ca4f83fba4fca8 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/TroughTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/TroughTests.cs index f8f6fffd5..f8d1c2a2c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/TroughTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/TroughTests.cs @@ -30,6 +30,52 @@ namespace VisualPinball.Unity.Test { public class TroughTests { + [Test] + public void ShouldPutDrainedBallPrefabsAtBackOfQueue() + { + var firstBall = new GameObject("First Ball"); + var secondBall = new GameObject("Second Ball"); + var thirdBall = new GameObject("Third Ball"); + try { + var queue = new BallPrefabQueue(new[] { firstBall, secondBall, thirdBall }, null, 3); + + queue.Peek().Should().BeSameAs(firstBall); + queue.Dequeue(); + queue.Peek().Should().BeSameAs(secondBall); + queue.Dequeue(); + + // The second ball drains before the first one. + queue.Enqueue(secondBall); + queue.Enqueue(firstBall); + + queue.Peek().Should().BeSameAs(thirdBall); + queue.Dequeue(); + queue.Peek().Should().BeSameAs(secondBall); + queue.Dequeue(); + queue.Peek().Should().BeSameAs(firstBall); + } finally { + Object.DestroyImmediate(firstBall); + Object.DestroyImmediate(secondBall); + Object.DestroyImmediate(thirdBall); + } + } + + [Test] + public void ShouldUseLegacyPrefabForEveryInitialBall() + { + var legacyBall = new GameObject("Legacy Ball"); + try { + var queue = new BallPrefabQueue(null, legacyBall, 3); + + for (var i = 0; i < 3; i++) { + queue.Peek().Should().BeSameAs(legacyBall); + queue.Dequeue(); + } + queue.Count.Should().Be(0); + } finally { + Object.DestroyImmediate(legacyBall); + } + } [Test] public void ShouldReturnCorrectSwitchesForModernOpto() diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs index ca315f2c3..9ab722862 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallComponent.cs @@ -29,6 +29,12 @@ public class BallComponent : MonoBehaviour public float3 Velocity; public bool IsFrozen; + /// + /// The prefab this runtime ball was instantiated from. Troughs use this to put the + /// correct visual ball back into their prefab queue after a drain. + /// + internal GameObject SourcePrefab { get; set; } + internal BallState CreateState() { var pos = transform.localPosition.TranslateToVpx(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs index 3492b5da9..96cf037f4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallManager.cs @@ -53,10 +53,19 @@ public int CreateBall(IBallCreationPosition ballCreator, float radius = 25f, flo var ballGo = Object.Instantiate(ballPrefab, _parent); var ballComp = ballGo.GetComponent(); + if (!ballComp) { + Object.DestroyImmediate(ballGo); + throw new InvalidOperationException($"Ball prefab '{ballPrefab.name}' must have a BallComponent on its root."); + } + if (ballComp.Radius <= 0f) { + Object.DestroyImmediate(ballGo); + throw new InvalidOperationException($"Ball prefab '{ballPrefab.name}' must have a radius greater than zero."); + } ballGo.name = $"Ball {NumBallsCreated++}"; - ballGo.transform.localScale = Physics.ScaleToWorld(new Vector3(radius, radius, radius) * 2f); + ballGo.transform.localScale *= radius / ballComp.Radius; ballGo.transform.localPosition = localPos.TranslateToWorld(); + ballComp.SourcePrefab = ballPrefab; ballComp.Radius = radius; ballComp.Mass = mass; ballComp.Velocity = ballCreator.GetBallCreationVelocity().ToUnityFloat3(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs index 453d3ba1c..14a2efa70 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughApi.cs @@ -152,6 +152,7 @@ public class TroughApi : ItemApi, /// This does not include balls sitting in drain before being pushed into the trough. /// private int _countedStackBalls; + private BallPrefabQueue _ballPrefabs; /// /// The player will ask for switches to hook up to the gamelogic engine, @@ -233,6 +234,7 @@ void IApi.OnInit(BallManager ballManager) // fill up the ball stack var ballCount = MainComponent.Type == TroughType.ClassicSingleBall ? 1 : MainComponent.BallCount; + _ballPrefabs = new BallPrefabQueue(MainComponent.BallPrefabs, MainComponent.Ball, ballCount); for (var i = 0; i < ballCount; i++) { AddBall(); } @@ -250,7 +252,7 @@ private void AddBall() case TroughType.ModernOpto: case TroughType.ModernMech: case TroughType.TwoCoilsNSwitches: - if (_countedStackBalls < MainComponent.BallCount) { + if (_countedStackBalls < _stackSwitches.Length) { _stackSwitches[_countedStackBalls].SetSwitch(true); _countedStackBalls++; } else { @@ -297,12 +299,15 @@ private void OnDrainKickerHit(object sender, HitEventArgs args) private void OnEntry(object sender, SwitchEventArgs args) { if (args.IsEnabled) { - Logger.Info("Draining ball into trough."); - if (_drainSwitch is KickerApi kickerApi) { - kickerApi.DestroyBall(); - } else { - BallManager.DestroyBall(args.BallId); + if (!PhysicsEngine.TryGetBall(args.BallId, out var ballComponent)) { + Logger.Warn($"Cannot drain ball {args.BallId}: the ball no longer exists."); + return; } + + Logger.Info("Draining ball into trough."); + var ballPrefab = ballComponent.SourcePrefab; + BallManager.DestroyBall(args.BallId); + _ballPrefabs.Enqueue(ballPrefab); DrainBall(); } else { @@ -491,8 +496,13 @@ public bool EjectBall() Logger.Warn("Trough: Cannot spawn ball without an exit kicker."); return false; } + if (_ballPrefabs == null || _ballPrefabs.Count == 0) { + Logger.Error("Trough: Ball prefab queue is empty while the trough still contains a ball."); + return false; + } Logger.Info("Trough: Spawning new ball."); - _ejectKicker.CreateBall(MainComponent.Ball); + _ejectKicker.CreateBall(_ballPrefabs.Peek()); + _ballPrefabs.Dequeue(); _ejectCoil.OnCoil(true); // open the switch of the ejected ball immediately @@ -684,4 +694,27 @@ void IApi.OnDestroy() #endregion } + + internal sealed class BallPrefabQueue + { + private readonly Queue _prefabs = new Queue(); + + internal int Count => _prefabs.Count; + + internal BallPrefabQueue(IReadOnlyList configuredPrefabs, GameObject legacyPrefab, int ballCount) + { + var useLegacyPrefab = configuredPrefabs == null || configuredPrefabs.Count == 0; + for (var i = 0; i < ballCount; i++) { + _prefabs.Enqueue(useLegacyPrefab + ? legacyPrefab + : i < configuredPrefabs.Count ? configuredPrefabs[i] : null); + } + } + + internal GameObject Peek() => _prefabs.Peek(); + + internal void Dequeue() => _prefabs.Dequeue(); + + internal void Enqueue(GameObject prefab) => _prefabs.Enqueue(prefab); + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughComponent.cs index 135957c0c..b41d75eb7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trough/TroughComponent.cs @@ -62,13 +62,17 @@ public ITriggerComponent PlayfieldEntrySwitch public KickerComponent PlayfieldExitKicker; public string PlayfieldExitKickerItem = string.Empty; - [Tooltip("The prefab that will instantiated when ejecting a new ball.")] + [HideInInspector] + [Tooltip("Legacy ball prefab. Kept so existing tables can be migrated to Ball Prefabs.")] public GameObject Ball; [Range(1, 10)] [Tooltip("How many balls the trough holds when the game starts.")] public int BallCount = 6; + [Tooltip("The ball prefabs in ejection order. A null entry uses the current render pipeline's default ball.")] + public GameObject[] BallPrefabs = Array.Empty(); + [Range(1, 10)] [Tooltip("How many ball switches are available.")] public int SwitchCount = 6;