From 57937b7432f6fbec1fb9005f012696f9cede6670 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Apr 2026 12:39:45 +0200 Subject: [PATCH 01/14] input: Add missing input key mappings. --- .../VisualPinball.Engine.csproj | 2 +- .../VisualPinball.Unity/Game/SwitchPlayer.cs | 25 +++-- .../VisualPinball.Unity/Input/InputManager.cs | 10 +- .../Simulation/NativeInputApi.cs | 96 +++++++++++++++---- .../Simulation/NativeInputManager.cs | 91 ++++++++++++------ .../Simulation/SimulationThread.cs | 72 +++++++++++++- 6 files changed, 229 insertions(+), 67 deletions(-) diff --git a/VisualPinball.Engine/VisualPinball.Engine.csproj b/VisualPinball.Engine/VisualPinball.Engine.csproj index 9d82bc04c..7ffda3c1d 100644 --- a/VisualPinball.Engine/VisualPinball.Engine.csproj +++ b/VisualPinball.Engine/VisualPinball.Engine.csproj @@ -15,7 +15,7 @@ https://visualpinball.org icon.png LICENSE - 0.0.2 + 0.0.3 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs index e4ebd5595..7f777a347 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs @@ -14,10 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using System.Collections.Generic; -using UnityEngine.InputSystem; -using NLog; -using Logger = NLog.Logger; +using System.Collections.Generic; +using UnityEngine.InputSystem; +using NLog; +using VisualPinball.Unity.Simulation; +using Logger = NLog.Logger; namespace VisualPinball.Unity { @@ -126,11 +127,17 @@ public void OnStart() } } - private void HandleKeyInput(object obj, InputActionChange change) - { - switch (change) { - case InputActionChange.ActionStarted: - case InputActionChange.ActionCanceled: + private void HandleKeyInput(object obj, InputActionChange change) + { + // Native input polling feeds SimulationThread directly. Ignore the legacy + // InputSystem switch path while native polling is active to avoid double-dispatch. + if (NativeInputManager.TryGetExistingInstance()?.IsPolling == true) { + return; + } + + switch (change) { + case InputActionChange.ActionStarted: + case InputActionChange.ActionCanceled: var action = (InputAction)obj; if (_keySwitchAssignments.TryGetValue(action.name, out var assignment)) { if (_player != null) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs index effc40b8a..e91910ed8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs @@ -150,10 +150,10 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionStartGame, InputActionType.Button, "/1"); map.AddAction(InputConstants.ActionPlunger, InputActionType.Button, "/enter"); map.AddAction(InputConstants.ActionPlungerAnalog, InputActionType.Button, "/rightStick/down"); - map.AddAction(InputConstants.ActionInsertCoin1, InputActionType.Button, "/3"); - map.AddAction(InputConstants.ActionInsertCoin2, InputActionType.Button, "/4"); - map.AddAction(InputConstants.ActionInsertCoin3, InputActionType.Button, "/5"); - map.AddAction(InputConstants.ActionInsertCoin4, InputActionType.Button, "/6"); + map.AddAction(InputConstants.ActionInsertCoin1, InputActionType.Button, "/5"); + map.AddAction(InputConstants.ActionInsertCoin2, InputActionType.Button, "/4"); + map.AddAction(InputConstants.ActionInsertCoin3, InputActionType.Button, "/3"); + map.AddAction(InputConstants.ActionInsertCoin4, InputActionType.Button, "/6"); map.AddAction(InputConstants.ActionCoinDoorOpenClose, InputActionType.Button, "/end"); map.AddAction(InputConstants.ActionCoinDoorCancel, InputActionType.Button, "/7"); map.AddAction(InputConstants.ActionCoinDoorDown, InputActionType.Button, "/8"); @@ -166,7 +166,7 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionCoinDoorPlus, InputActionType.Button, "/9"); map.AddAction(InputConstants.ActionCoinDoorSelect, InputActionType.Button, "/0"); map.AddAction(InputConstants.ActionSlamTilt, InputActionType.Button, "/home"); - map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button, "/8"); + map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button, "/7"); map.AddAction(InputConstants.ActionLeftAdvance, InputActionType.Button, "/a"); map.AddAction(InputConstants.ActionRightAdvance, InputActionType.Button, "/quote"); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index c0bd7e353..9efdad668 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -22,24 +22,43 @@ public static class NativeInputApi /// /// Input action enum (must match native enum) /// - public enum InputAction - { - LeftFlipper = 0, - RightFlipper = 1, - UpperLeftFlipper = 2, - UpperRightFlipper = 3, - LeftMagnasave = 4, - RightMagnasave = 5, - Start = 6, - Plunge = 7, - PlungerAnalog = 8, - CoinInsert1 = 9, - CoinInsert2 = 10, - CoinInsert3 = 11, - CoinInsert4 = 12, - ExitGame = 13, - SlamTilt = 14, - } + public enum InputAction + { + LeftFlipper = 0, + RightFlipper = 1, + UpperLeftFlipper = 2, + UpperRightFlipper = 3, + LeftMagnasave = 4, + RightMagnasave = 5, + Start = 6, + Plunge = 7, + PlungerAnalog = 8, + CoinInsert1 = 9, + CoinInsert2 = 10, + CoinInsert3 = 11, + CoinInsert4 = 12, + ExitGame = 13, + SlamTilt = 14, + LeftStagedFlipper = 15, + RightStagedFlipper = 16, + LeftNudge = 17, + RightNudge = 18, + CenterNudge = 19, + Tilt = 20, + ExtraBall = 21, + Lockbar = 22, + PauseGame = 23, + CoinDoor = 24, + Reset = 25, + Service1 = 26, + Service2 = 27, + Service3 = 28, + Service4 = 29, + Service5 = 30, + Service6 = 31, + Service7 = 32, + Service8 = 33, + } /// /// Input binding type @@ -60,17 +79,58 @@ public enum KeyCode RShift = 0xA1, LControl = 0xA2, RControl = 0xA3, + LAlt = 0xA4, + RAlt = 0xA5, + + Escape = 0x1B, Space = 0x20, + PageUp = 0x21, + PageDown = 0x22, + End = 0x23, + Home = 0x24, Return = 0x0D, + + F1 = 0x70, + F2 = 0x71, + F3 = 0x72, + F4 = 0x73, + F5 = 0x74, + F6 = 0x75, + F7 = 0x76, + F8 = 0x77, + F9 = 0x78, + F10 = 0x79, + F11 = 0x7A, + F12 = 0x7B, + + D0 = 0x30, D1 = 0x31, Num1 = 0x31, // alias for top-row '1' + D2 = 0x32, + D3 = 0x33, + D4 = 0x34, D5 = 0x35, Num5 = 0x35, // alias for top-row '5' + D6 = 0x36, + D7 = 0x37, + D8 = 0x38, + D9 = 0x39, + + O = 0x4F, + P = 0x50, + T = 0x54, + Y = 0x59, Numpad1 = 0x61, + A = 0x41, + B = 0x42, S = 0x53, D = 0x44, W = 0x57, + + Minus = 0xBD, // VK_OEM_MINUS + Quote = 0xDE, // VK_OEM_7 + Caret = 0xC0, // VK_OEM_3 (layout dependent) } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index f1f4d5bc4..e7d48e393 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -66,13 +66,15 @@ public static NativeInputManager Instance } } - public static NativeInputManager TryGetExistingInstance() - { - return Volatile.Read(ref _instance); - } - - public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; - public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; + public static NativeInputManager TryGetExistingInstance() + { + return Volatile.Read(ref _instance); + } + + public bool IsPolling => _polling; + + public float TargetPollingHz => _polling && _pollIntervalUs > 0 ? 1000000f / _pollIntervalUs : 0f; + public float ActualEventRateHz => _polling ? Volatile.Read(ref _actualEventRateHz) : 0f; private NativeInputManager() { @@ -203,29 +205,58 @@ public void StopPolling() /// /// Setup default input bindings /// - private void SetupDefaultBindings() - { - ClearBindings(); - - // Flippers - AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.LShift); - AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.RShift); - // Fallback keys (useful when modifier VKs are unreliable in some contexts) - AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.A); - AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.D); - - // Start - AddBinding(NativeInputApi.InputAction.Start, NativeInputApi.KeyCode.D1); - - // Coin - AddBinding(NativeInputApi.InputAction.CoinInsert1, NativeInputApi.KeyCode.D5); - - // Plunger (align with Unity InputManager defaults: Enter) - AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Return); - AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Space); - - Logger.Info($"{LogPrefix} [NativeInputManager] Configured {_bindings.Count} default bindings"); - } + private void SetupDefaultBindings() + { + ClearBindings(); + + // Flippers + AddBinding(NativeInputApi.InputAction.LeftFlipper, NativeInputApi.KeyCode.LShift); + AddBinding(NativeInputApi.InputAction.RightFlipper, NativeInputApi.KeyCode.RShift); + AddBinding(NativeInputApi.InputAction.LeftStagedFlipper, NativeInputApi.KeyCode.LShift); + AddBinding(NativeInputApi.InputAction.RightStagedFlipper, NativeInputApi.KeyCode.RShift); + AddBinding(NativeInputApi.InputAction.UpperLeftFlipper, NativeInputApi.KeyCode.A); + AddBinding(NativeInputApi.InputAction.UpperRightFlipper, NativeInputApi.KeyCode.Quote); + + // Magna saves + AddBinding(NativeInputApi.InputAction.LeftMagnasave, NativeInputApi.KeyCode.LControl); + AddBinding(NativeInputApi.InputAction.RightMagnasave, NativeInputApi.KeyCode.RControl); + + // Start + AddBinding(NativeInputApi.InputAction.Start, NativeInputApi.KeyCode.D1); + + // Coin chutes + AddBinding(NativeInputApi.InputAction.CoinInsert1, NativeInputApi.KeyCode.D5); + AddBinding(NativeInputApi.InputAction.CoinInsert2, NativeInputApi.KeyCode.D4); + AddBinding(NativeInputApi.InputAction.CoinInsert3, NativeInputApi.KeyCode.D3); + AddBinding(NativeInputApi.InputAction.CoinInsert4, NativeInputApi.KeyCode.D6); + + // Cabinet/service controls + AddBinding(NativeInputApi.InputAction.ExtraBall, NativeInputApi.KeyCode.B); + AddBinding(NativeInputApi.InputAction.Lockbar, NativeInputApi.KeyCode.LAlt); + AddBinding(NativeInputApi.InputAction.PauseGame, NativeInputApi.KeyCode.P); + AddBinding(NativeInputApi.InputAction.ExitGame, NativeInputApi.KeyCode.Escape); + AddBinding(NativeInputApi.InputAction.SlamTilt, NativeInputApi.KeyCode.Home); + AddBinding(NativeInputApi.InputAction.CoinDoor, NativeInputApi.KeyCode.End); + AddBinding(NativeInputApi.InputAction.Reset, NativeInputApi.KeyCode.F3); + AddBinding(NativeInputApi.InputAction.Service1, NativeInputApi.KeyCode.D7); + AddBinding(NativeInputApi.InputAction.Service2, NativeInputApi.KeyCode.D8); + AddBinding(NativeInputApi.InputAction.Service3, NativeInputApi.KeyCode.D9); + AddBinding(NativeInputApi.InputAction.Service4, NativeInputApi.KeyCode.D0); + AddBinding(NativeInputApi.InputAction.Service5, NativeInputApi.KeyCode.D6); + AddBinding(NativeInputApi.InputAction.Service6, NativeInputApi.KeyCode.PageUp); + AddBinding(NativeInputApi.InputAction.Service7, NativeInputApi.KeyCode.Quote); + + // Nudging + AddBinding(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Y); + AddBinding(NativeInputApi.InputAction.RightNudge, NativeInputApi.KeyCode.Minus); + AddBinding(NativeInputApi.InputAction.CenterNudge, NativeInputApi.KeyCode.Space); + AddBinding(NativeInputApi.InputAction.Tilt, NativeInputApi.KeyCode.T); + + // Plunger + AddBinding(NativeInputApi.InputAction.Plunge, NativeInputApi.KeyCode.Return); + + Logger.Info($"{LogPrefix} [NativeInputManager] Configured {_bindings.Count} default bindings"); + } /// /// Input event callback from native layer (called on input polling thread) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index f81684646..8e7c4dbc8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -77,6 +77,9 @@ public class SimulationThread : IDisposable // Input state tracking (allocation-free indexed arrays) private readonly bool[] _actionStates; private readonly string[] _actionToSwitchId; + private readonly bool[] _actionInvertsPressed; + private readonly bool[] _actionToggleOnPress; + private readonly bool[] _actionSwitchStates; private volatile bool _inputMappingsBuilt; // Statistics @@ -136,6 +139,9 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE var actionCount = Enum.GetValues(typeof(NativeInputApi.InputAction)).Length; _actionStates = new bool[actionCount]; _actionToSwitchId = new string[actionCount]; + _actionInvertsPressed = new bool[actionCount]; + _actionToggleOnPress = new bool[actionCount]; + _actionSwitchStates = new bool[actionCount]; _needsInitialSwitchSync = true; if (_gamelogicEngine != null) { @@ -524,12 +530,24 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) return; } + bool isClosed; + if (_actionToggleOnPress[actionIndex]) { + // VP-style coin door behavior toggles only on key-down. + if (!isPressed) { + return; + } + isClosed = !_actionSwitchStates[actionIndex]; + } else { + isClosed = _actionInvertsPressed[actionIndex] ? !isPressed : isPressed; + } + _actionSwitchStates[actionIndex] = isClosed; + _lastSwitchDispatchUsec = GetTimestampUsec(); if (isPressed && IsFlipperAction(actionIndex)) { _lastFlipperInputUsec = _lastSwitchDispatchUsec; } if (actionIndex == (int)NativeInputApi.InputAction.Start && Logger.IsInfoEnabled) { - Logger.Info($"{LogPrefix} [SimulationThread] Input Start -> Switch({switchId}, {isPressed})"); + Logger.Info($"{LogPrefix} [SimulationThread] Input Start -> Switch({switchId}, {isClosed})"); } if (Logger.IsInfoEnabled && isPressed) { if (actionIndex == (int)NativeInputApi.InputAction.LeftFlipper) { @@ -539,13 +557,15 @@ private void SendMappedSwitch(int actionIndex, bool isPressed) Logger.Info($"{LogPrefix} [SimulationThread] Input RightFlipper -> Switch({switchId}, True)"); } } - _inputDispatcher.DispatchSwitch(switchId, isPressed); + _inputDispatcher.DispatchSwitch(switchId, isClosed); } private static bool IsFlipperAction(int actionIndex) { return actionIndex == (int)NativeInputApi.InputAction.LeftFlipper || actionIndex == (int)NativeInputApi.InputAction.RightFlipper + || actionIndex == (int)NativeInputApi.InputAction.LeftStagedFlipper + || actionIndex == (int)NativeInputApi.InputAction.RightStagedFlipper || actionIndex == (int)NativeInputApi.InputAction.UpperLeftFlipper || actionIndex == (int)NativeInputApi.InputAction.UpperRightFlipper; } @@ -558,7 +578,7 @@ private void SyncAllMappedSwitches() if (switchId == null) { continue; } - _inputDispatcher.DispatchSwitch(switchId, _actionStates[i]); + _inputDispatcher.DispatchSwitch(switchId, _actionSwitchStates[i]); } } @@ -575,6 +595,9 @@ private void BuildInputMappingsIfNeeded() private void BuildInputMappings() { Array.Clear(_actionToSwitchId, 0, _actionToSwitchId.Length); + Array.Clear(_actionInvertsPressed, 0, _actionInvertsPressed.Length); + Array.Clear(_actionToggleOnPress, 0, _actionToggleOnPress.Length); + Array.Clear(_actionSwitchStates, 0, _actionSwitchStates.Length); if (_gamelogicEngine == null) { return; @@ -598,7 +621,16 @@ private void BuildInputMappings() } // Prefer the first mapping we see. - _actionToSwitchId[actionIndex] ??= sw.Id; + if (_actionToSwitchId[actionIndex] != null) { + continue; + } + + _actionToSwitchId[actionIndex] = sw.Id; + _actionInvertsPressed[actionIndex] = sw.NormallyClosed; + _actionToggleOnPress[actionIndex] = sw.InputActionHint == InputConstants.ActionCoinDoorOpenClose; + _actionSwitchStates[actionIndex] = _actionToggleOnPress[actionIndex] + ? sw.NormallyClosed + : (sw.NormallyClosed ? !_actionStates[actionIndex] : _actionStates[actionIndex]); } if (Logger.IsDebugEnabled) @@ -676,6 +708,38 @@ private static bool TryMapInputActionHint(string inputActionHint, out NativeInpu action = NativeInputApi.InputAction.CoinInsert4; return true; } + if (inputActionHint == InputConstants.ActionCoinDoorOpenClose) { + action = NativeInputApi.InputAction.CoinDoor; + return true; + } + if (inputActionHint == InputConstants.ActionCoinDoorCancel || inputActionHint == InputConstants.ActionCoinDoorBack || inputActionHint == InputConstants.ActionCoinDoorUpDown) { + action = NativeInputApi.InputAction.Service1; + return true; + } + if (inputActionHint == InputConstants.ActionCoinDoorDown || inputActionHint == InputConstants.ActionCoinDoorMinus || inputActionHint == InputConstants.ActionCoinDoorAdvance) { + action = NativeInputApi.InputAction.Service2; + return true; + } + if (inputActionHint == InputConstants.ActionCoinDoorUp || inputActionHint == InputConstants.ActionCoinDoorPlus) { + action = NativeInputApi.InputAction.Service3; + return true; + } + if (inputActionHint == InputConstants.ActionCoinDoorEnter || inputActionHint == InputConstants.ActionCoinDoorSelect) { + action = NativeInputApi.InputAction.Service4; + return true; + } + if (inputActionHint == InputConstants.ActionSelfTest) { + action = NativeInputApi.InputAction.Service1; + return true; + } + if (inputActionHint == InputConstants.ActionLeftAdvance) { + action = NativeInputApi.InputAction.UpperLeftFlipper; + return true; + } + if (inputActionHint == InputConstants.ActionRightAdvance) { + action = NativeInputApi.InputAction.UpperRightFlipper; + return true; + } if (inputActionHint == InputConstants.ActionSlamTilt) { action = NativeInputApi.InputAction.SlamTilt; return true; From f35c8fb56a478c5cbaf3790dc6e6dfe5295c7bba Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 12 Apr 2026 13:06:29 +0200 Subject: [PATCH 02/14] lights: Hide faux bulb when light is off. --- .../Managers/Lamp/LampManager.cs | 123 +++++++++++++----- .../VPT/Light/LightComponent.cs | 103 +++++++++++---- 2 files changed, 164 insertions(+), 62 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs index 14ef33c64..03b2a4f54 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs @@ -17,12 +17,12 @@ using System; using System.Collections.Generic; using System.Linq; -using NLog; -using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Game.Engines; -using Logger = NLog.Logger; -using Object = UnityEngine.Object; +using NLog; +using UnityEditor; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using Logger = NLog.Logger; +using Object = UnityEngine.Object; namespace VisualPinball.Unity.Editor { @@ -113,16 +113,16 @@ protected override void OnButtonBarGUI() GUILayout.FlexibleSpace(); - _toggleAction = (ToggleAction)EditorGUILayout.EnumPopup(_toggleAction); - if (GUILayout.Button("Turn On", GUILayout.ExpandWidth(false))) { - Toggle(l => l.enabled = true); - } - if (GUILayout.Button("Turn Off", GUILayout.ExpandWidth(false))) { - Toggle(l => l.enabled = false); - } - if (GUILayout.Button("Select", GUILayout.ExpandWidth(false))) { - var lights = new List(); - Toggle(lights.Add); + _toggleAction = (ToggleAction)EditorGUILayout.EnumPopup(_toggleAction); + if (GUILayout.Button("Turn On", GUILayout.ExpandWidth(false))) { + ToggleLampState(true); + } + if (GUILayout.Button("Turn Off", GUILayout.ExpandWidth(false))) { + ToggleLampState(false); + } + if (GUILayout.Button("Select", GUILayout.ExpandWidth(false))) { + var lights = new List(); + Toggle(lights.Add); Selection.objects = _toggleSource ? lights.Select(l => l.gameObject as Object).ToArray() : lights.Select(l => l.gameObject.transform.parent.gameObject as Object).ToArray(); @@ -130,29 +130,82 @@ protected override void OnButtonBarGUI() _toggleSource = GUILayout.Toggle(_toggleSource, "Source"); } - private void Toggle(Action action) - { - if (TableComponent != null) { - IEnumerable selection = _toggleAction switch { - ToggleAction.All => TableComponent.MappingConfig.Lamps, - ToggleAction.Inserts => TableComponent.MappingConfig.Lamps.Where(lm => !lm.IsCoil && lm.Source == LampSource.Lamp), - ToggleAction.GI => TableComponent.MappingConfig.Lamps.Where(lm => lm.Source == LampSource.GI), - ToggleAction.Flasher => TableComponent.MappingConfig.Lamps.Where(lm => lm.IsCoil), - ToggleAction.Selected => _listView.GetSelectedData().Select(lld => lld.LampMapping), - _ => throw new ArgumentOutOfRangeException() - }; - - foreach (var lampMapping in selection) { - if (lampMapping.Device == null) { - continue; - } + private void Toggle(Action action) + { + if (TableComponent != null) { + foreach (var lampMapping in GetSelectedMappings()) { + if (lampMapping.Device == null) { + continue; + } foreach (var light in lampMapping.Device.LightSources) { action(light); } - } - } - } + } + } + } + + private IEnumerable GetSelectedMappings() + { + return _toggleAction switch { + ToggleAction.All => TableComponent.MappingConfig.Lamps, + ToggleAction.Inserts => TableComponent.MappingConfig.Lamps.Where(lm => !lm.IsCoil && lm.Source == LampSource.Lamp), + ToggleAction.GI => TableComponent.MappingConfig.Lamps.Where(lm => lm.Source == LampSource.GI), + ToggleAction.Flasher => TableComponent.MappingConfig.Lamps.Where(lm => lm.IsCoil), + ToggleAction.Selected => _listView.GetSelectedData().Select(lld => lld.LampMapping), + _ => throw new ArgumentOutOfRangeException() + }; + } + + private void ToggleLampState(bool enabled) + { + if (TableComponent == null) { + return; + } + + // In play mode, drive lamp APIs so LightComponent updates both Unity lights and emissive materials. + // Outside play mode, fall back to directly toggling the runtime light components. + var player = TableComponent.GetComponentInParent() ?? TableComponent.GetComponentInChildren(); + foreach (var lampMapping in GetSelectedMappings()) { + var device = lampMapping.Device; + if (device == null) { + continue; + } + + var handledByApi = false; + if (Application.isPlaying && player != null) { + try { + device.GetApi(player).OnLamp(enabled ? LampStatus.On : LampStatus.Off); + handledByApi = true; + } catch (Exception ex) { + Logger.Warn(ex, $"Failed to toggle lamp via API for device \"{(device as Component)?.name}\"."); + } + } + + if (!handledByApi) { + SetLampDeviceEnabled(device, enabled); + } + } + } + + private static void SetLampDeviceEnabled(ILampDeviceComponent device, bool enabled) + { + switch (device) { + case LightComponent lightComponent: + lightComponent.Enabled = enabled; + break; + case LightGroupComponent lightGroup: + foreach (var child in lightGroup.Lights.Where(child => child != null)) { + SetLampDeviceEnabled(child, enabled); + } + break; + default: + foreach (var light in device.LightSources.Where(light => light != null)) { + light.enabled = enabled; + } + break; + } + } protected override void OnListViewItemRenderer(LampListData data, Rect cellRect, int column) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightComponent.cs index b66f69106..a55f88c74 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightComponent.cs @@ -88,9 +88,12 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac protected override Type MeshComponentType { get; } = typeof(MeshComponent); protected override Type ColliderComponentType { get; } = null; - public const string LampIdDefault = "default_lamp"; - private const string BulbMeshName = "Light (Bulb)"; - private const string SocketMeshName = "Light (Socket)"; + public const string LampIdDefault = "default_lamp"; + private const string BulbMeshName = "Light (Bulb)"; + private const string SocketMeshName = "Light (Socket)"; + + private static readonly int BaseColor = Shader.PropertyToID("_BaseColor"); + private static readonly int ColorProperty = Shader.PropertyToID("_Color"); private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -167,10 +170,11 @@ public override void UpdateTransforms() private float _oldValueAt; private Color _color; - private bool _hasLights; - private readonly List<(Light, float)> _lights = new(); - private readonly List<(Renderer, float)> _materials = new(); - private MaterialPropertyBlock _propBlock; + private bool _hasLights; + private readonly List<(Light, float)> _lights = new(); + private readonly List<(Renderer, float)> _materials = new(); + private readonly List<(Renderer renderer, Color baseColor, bool hasBaseColor, bool hasColor)> _fauxBulbs = new(); + private MaterialPropertyBlock _propBlock; public bool Enabled { set { @@ -219,18 +223,31 @@ private void Awake() } } - // remember material emissions - _propBlock = new MaterialPropertyBlock(); // this is just something we can recycle - foreach (var mr in GetComponentsInChildren()) { - var emissiveIntensity = RenderPipeline.Current.MaterialConverter.GetEmissiveIntensity(mr.sharedMaterial); - if (emissiveIntensity > 0) { - _materials.Add((mr, emissiveIntensity)); - } - // todo set to 0 initially - } - - _hasLights = _lights.Count > 0 || _materials.Count > 0; - } + // remember material emissions + _propBlock = new MaterialPropertyBlock(); // this is just something we can recycle + foreach (var mr in GetComponentsInChildren()) { + var emissiveIntensity = RenderPipeline.Current.MaterialConverter.GetEmissiveIntensity(mr.sharedMaterial); + if (emissiveIntensity > 0) { + _materials.Add((mr, emissiveIntensity)); + + // Treat any emissive renderer as a faux-bulb style visual that should + // dim/hide with lamp intensity, independent of object naming. + var hasBaseColor = mr.sharedMaterial.HasProperty(BaseColor); + var hasColor = mr.sharedMaterial.HasProperty(ColorProperty); + var baseColor = hasBaseColor + ? mr.sharedMaterial.GetColor(BaseColor) + : hasColor ? mr.sharedMaterial.GetColor(ColorProperty) : Color.white; + _fauxBulbs.Add((mr, baseColor, hasBaseColor, hasColor)); + } + } + // Ensure emissive meshes start dark until the first lamp event updates them. + // Without this, materials with baked emissive defaults (e.g. FauxBulb) can + // appear lit even while the logical lamp value is off. + SetMaterialIntensity(0f); + SetFauxBulbVisibility(0f); + + _hasLights = _lights.Count > 0 || _materials.Count > 0; + } private void Update() { @@ -342,14 +359,46 @@ private void SetLightIntensity(float value) } } - private void SetMaterialIntensity(float value) - { - foreach (var (mr, intensity) in _materials) { - mr.GetPropertyBlock(_propBlock); - RenderPipeline.Current.MaterialConverter.SetEmissiveIntensity(mr.sharedMaterial, _propBlock, value * intensity); - mr.SetPropertyBlock(_propBlock); - } - } + private void SetMaterialIntensity(float value) + { + foreach (var (mr, intensity) in _materials) { + mr.GetPropertyBlock(_propBlock); + RenderPipeline.Current.MaterialConverter.SetEmissiveIntensity(mr.sharedMaterial, _propBlock, value * intensity); + mr.SetPropertyBlock(_propBlock); + } + SetFauxBulbVisibility(value); + } + + private void SetFauxBulbVisibility(float value) + { + if (_fauxBulbs.Count == 0) { + return; + } + + var clampedValue = Mathf.Clamp01(value); + foreach (var (renderer, baseColor, hasBaseColor, hasColor) in _fauxBulbs) { + if (!renderer) { + continue; + } + + renderer.enabled = clampedValue > 0.001f; + + renderer.GetPropertyBlock(_propBlock); + var faded = new Color( + baseColor.r * clampedValue, + baseColor.g * clampedValue, + baseColor.b * clampedValue, + baseColor.a * clampedValue + ); + if (hasBaseColor) { + _propBlock.SetColor(BaseColor, faded); + } + if (hasColor) { + _propBlock.SetColor(ColorProperty, faded); + } + renderer.SetPropertyBlock(_propBlock); + } + } #endregion From e0b5887ffc90320227e18f1f905a2b5b999eadbe Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 17 Apr 2026 00:33:47 +0200 Subject: [PATCH 03/14] deps: Bump native input to v0.0.4. --- .../VisualPinball.Engine.csproj | 18 ++++++++++-------- .../Documentation~/developer-guide/setup.md | 2 ++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/VisualPinball.Engine/VisualPinball.Engine.csproj b/VisualPinball.Engine/VisualPinball.Engine.csproj index 7ffda3c1d..43fee5a42 100644 --- a/VisualPinball.Engine/VisualPinball.Engine.csproj +++ b/VisualPinball.Engine/VisualPinball.Engine.csproj @@ -15,7 +15,7 @@ https://visualpinball.org icon.png LICENSE - 0.0.3 + 0.0.4 @@ -28,15 +28,16 @@ - - - - - - - + + + + + + + + @@ -75,6 +76,7 @@ + diff --git a/VisualPinball.Unity/Documentation~/developer-guide/setup.md b/VisualPinball.Unity/Documentation~/developer-guide/setup.md index 165217b54..655e3cdfd 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/setup.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/setup.md @@ -77,6 +77,8 @@ Unity automatically creates a `.meta` file for every file and directory it index The thing is, in the main repo there are a few native dependencies that we don't include directly. Instead, we reference them through NuGet and copy them to Unity's Plugin folder when compiling for the first time. That means that in the repo, we have the `.meta` files for those dependencies, but not the actual files, which results in Unity cleaning the `.meta` files for all platforms when compiling. +`VisualPinball.NativeInput` follows the same model: build/package it in its own repository, publish to NuGet, then let `VisualPinball.Engine` restore and copy the runtime binary into `VisualPinball.Unity/Plugins/`. + Long story short, you'll end up with something like this very soon: ![Delete .meta files in git](removed-meta-files.png) From abcc857c165a9262a7e155df74b3f9fcb19a0586 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 17 Apr 2026 00:52:13 +0200 Subject: [PATCH 04/14] import: Add option to skip images and sound, only import collidables, and apply same material to all objects. --- .../Import/MenuImporter.cs | 22 +- .../Import/VpxImportWizard.cs | 87 ++++-- .../Import/VpxImportWizardSettings.cs | 179 +++++++++---- .../Import/VpxSceneConverter.cs | 253 ++++++++++++------ .../Utils/AssetReferenceLocator.cs | 24 +- .../Import/ImportContext.cs | 34 +++ .../Import/ImportContext.cs.meta | 2 + .../VPT/MainRenderableComponent.cs | 13 +- .../VPT/Ramp/RampComponent.cs | 171 ++++++++---- .../VPT/Ramp/RampFloorMeshComponent.cs | 14 +- .../VPT/Ramp/RampWallMeshComponent.cs | 14 +- .../VPT/Ramp/RampWireMeshComponent.cs | 14 +- 12 files changed, 580 insertions(+), 247 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs index 897d9f85f..802df0692 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/MenuImporter.cs @@ -57,6 +57,26 @@ public static async void ImportVpeIntoScene(MenuCommand menuCommand) break; } } + + [MenuItem("Pinball/Import Wizard", false, 2)] + public static void ImportVpxWithFilters(MenuCommand menuCommand) + { + if (!EnsureUntitledSceneHasBeenSaved()) { + return; + } + + var path = EditorUtility.OpenFilePanelWithFilters("Import Wizard", null, new[] { "Visual Pinball Table Files", "vpx" }); + if (path.Length == 0) { + return; + } + + VpxImportWizardSettings.VpxPath = path; + VpxImportWizardSettings.TableName = Path.GetFileNameWithoutExtension(path); + VpxImportWizardSettings.ObjectImportFilter = VpxObjectImportFilter.CollidableOnly; + VpxImportWizardSettings.ImportTextures = false; + VpxImportWizardSettings.ImportSounds = false; + VpxImportWizard.Init(); + } private static bool EnsureUntitledSceneHasBeenSaved() { @@ -78,4 +98,4 @@ private static bool EnsureUntitledSceneHasBeenSaved() return true; } } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs index 4c88e1063..20bbb2ee9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs @@ -16,10 +16,11 @@ using System; using System.IO; -using NLog; -using UnityEditor; -using UnityEngine; -using Logger = NLog.Logger; +using NLog; +using UnityEditor; +using UnityEngine; +using Logger = NLog.Logger; +using Material = UnityEngine.Material; namespace VisualPinball.Unity.Editor { @@ -135,13 +136,55 @@ public void OnGUI() GUILayout.Space(settingsMargin); - VpxImportWizardSettings.TableName = EditorGUILayout.TextField("Table Name", VpxImportWizardSettings.TableName); - - EditorGUILayout.LabelField("The name of the gameobject. Empty = default. Tags: %TABLENAME% = table name, %INFONAME% = Table's Info Name", labelInfoStyle); - - - GUILayout.FlexibleSpace(); - } + VpxImportWizardSettings.TableName = EditorGUILayout.TextField("Table Name", VpxImportWizardSettings.TableName); + + EditorGUILayout.LabelField("The name of the gameobject. Empty = default. Tags: %TABLENAME% = table name, %INFONAME% = Table's Info Name", labelInfoStyle); + + GUILayout.Space(settingsMargin); + + VpxImportWizardSettings.ObjectImportFilter = (VpxObjectImportFilter)EditorGUILayout.EnumPopup("Object Filter", VpxImportWizardSettings.ObjectImportFilter); + + EditorGUILayout.LabelField("Choose between importing all renderables or only physics-loop object types (including items with collision currently disabled).", labelInfoStyle); + + var collidableOnlyImport = VpxImportWizardSettings.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + + if (collidableOnlyImport) { + VpxImportWizardSettings.ImportTextures = false; + VpxImportWizardSettings.ImportSounds = false; + } + + GUILayout.Space(settingsMargin); + + using (new EditorGUI.DisabledScope(collidableOnlyImport)) { + VpxImportWizardSettings.ImportTextures = EditorGUILayout.Toggle("Import Images", VpxImportWizardSettings.ImportTextures); + } + EditorGUILayout.LabelField(collidableOnlyImport + ? "Disabled for collidable-only import." + : "Imports VPX images/textures into the table asset folder.", labelInfoStyle); + + GUILayout.Space(settingsMargin); + + using (new EditorGUI.DisabledScope(collidableOnlyImport)) { + VpxImportWizardSettings.ImportSounds = EditorGUILayout.Toggle("Import Sounds", VpxImportWizardSettings.ImportSounds); + } + EditorGUILayout.LabelField(collidableOnlyImport + ? "Disabled for collidable-only import." + : "Imports VPX sound assets into the table asset folder.", labelInfoStyle); + + GUILayout.Space(settingsMargin); + + VpxImportWizardSettings.ForceAllObjectsVisible = EditorGUILayout.Toggle("Force All Visible", VpxImportWizardSettings.ForceAllObjectsVisible); + EditorGUILayout.LabelField("Forces imported objects and child meshes to be visible, ignoring VPX visibility flags.", labelInfoStyle); + + GUILayout.Space(settingsMargin); + + VpxImportWizardSettings.OverrideVisualMaterial = (Material)EditorGUILayout.ObjectField("Override Material", VpxImportWizardSettings.OverrideVisualMaterial, typeof(Material), false); + + EditorGUILayout.LabelField("If set, all imported renderers use this material and no visual materials or texture assets are created.", labelInfoStyle); + + + GUILayout.FlexibleSpace(); + } EditorGUILayout.EndVertical(); #endregion Settings @@ -154,14 +197,20 @@ public void OnGUI() GUILayout.FlexibleSpace(); //GUI.backgroundColor = Color.red; - if (GUILayout.Button("Import", GUILayout.Width(100), GUILayout.Height(30))) - { - if (File.Exists(VpxImportWizardSettings.VpxPath)) - { - VpxImportEngine.ImportIntoScene(VpxImportWizardSettings.VpxPath, null, VpxImportWizardSettings.ApplyPatch, VpxImportWizardSettings.TableName); - } - else - { + if (GUILayout.Button("Import", GUILayout.Width(100), GUILayout.Height(30))) + { + if (File.Exists(VpxImportWizardSettings.VpxPath)) + { + VpxImportEngine.ImportIntoScene( + VpxImportWizardSettings.VpxPath, + null, + VpxImportWizardSettings.ApplyPatch, + VpxImportWizardSettings.TableName, + VpxImportWizardSettings.BuildConvertOptions() + ); + } + else + { Logger.Error("VPX file not found: {0}", VpxImportWizardSettings.VpxPath); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs index 58609f6a8..2a2c32b1c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs @@ -1,57 +1,122 @@ -// Visual Pinball Engine -// Copyright (C) 2023 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.IO; -using UnityEditor; -using UnityEngine; - -namespace VisualPinball.Unity.Editor -{ - [Serializable] - public class VpxImportWizardSettings : MonoBehaviour - { - public static bool ApplyPatch - { - get => EditorPrefs.GetBool("ApplyPatch", true); - set => EditorPrefs.SetBool("ApplyPatch", value); - } - - public static string VpxPath - { - get => EditorPrefs.GetString("VpxPath", ""); - set => EditorPrefs.SetString("VpxPath", value); - } - - public static string TableName - { - get => EditorPrefs.GetString("TableName", "%TABLENAME%"); - set => EditorPrefs.SetString("TableName", value); - } - - public static bool IsPathValid() - { - return !string.IsNullOrEmpty(VpxPath) && File.Exists(VpxPath); - } - - public static void Reset() - { - VpxPath = ""; - ApplyPatch = true; - TableName = "%TABLENAME%"; - } - } -} +// Visual Pinball Engine +// Copyright (C) 2023 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.IO; +using UnityEditor; +using UnityEngine; +using Material = UnityEngine.Material; + +namespace VisualPinball.Unity.Editor +{ + [Serializable] + public static class VpxImportWizardSettings + { + public static bool ApplyPatch + { + get => EditorPrefs.GetBool("ApplyPatch", true); + set => EditorPrefs.SetBool("ApplyPatch", value); + } + + public static string VpxPath + { + get => EditorPrefs.GetString("VpxPath", ""); + set => EditorPrefs.SetString("VpxPath", value); + } + + public static string TableName + { + get => EditorPrefs.GetString("TableName", "%TABLENAME%"); + set => EditorPrefs.SetString("TableName", value); + } + + public static VpxObjectImportFilter ObjectImportFilter + { + get => (VpxObjectImportFilter)EditorPrefs.GetInt("ObjectImportFilter", (int)VpxObjectImportFilter.All); + set => EditorPrefs.SetInt("ObjectImportFilter", (int)value); + } + + public static bool ImportTextures + { + get => EditorPrefs.GetBool("ImportTextures", true); + set => EditorPrefs.SetBool("ImportTextures", value); + } + + public static bool ImportSounds + { + get => EditorPrefs.GetBool("ImportSounds", true); + set => EditorPrefs.SetBool("ImportSounds", value); + } + + public static bool ForceAllObjectsVisible + { + get => EditorPrefs.GetBool("ForceAllObjectsVisible", false); + set => EditorPrefs.SetBool("ForceAllObjectsVisible", value); + } + + public static Material OverrideVisualMaterial + { + get { + var materialPath = EditorPrefs.GetString("OverrideVisualMaterialPath", ""); + return string.IsNullOrEmpty(materialPath) + ? null + : AssetDatabase.LoadAssetAtPath(materialPath); + } + set { + var materialPath = value != null + ? AssetDatabase.GetAssetPath(value) + : string.Empty; + EditorPrefs.SetString("OverrideVisualMaterialPath", materialPath); + } + } + + public static ConvertOptions BuildConvertOptions() + { + var options = new ConvertOptions { + ObjectImportFilter = ObjectImportFilter, + ImportTextures = ImportTextures, + ImportSounds = ImportSounds, + ForceAllObjectsVisible = ForceAllObjectsVisible, + OverrideVisualMaterial = OverrideVisualMaterial + }; + + if (options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly) { + options.SkipExistingMeshes = false; + options.ImportTextures = false; + options.ImportSounds = false; + } + + return options; + } + + public static bool IsPathValid() + { + return !string.IsNullOrEmpty(VpxPath) && File.Exists(VpxPath); + } + + public static void Reset() + { + VpxPath = ""; + ApplyPatch = true; + TableName = "%TABLENAME%"; + ObjectImportFilter = VpxObjectImportFilter.All; + ImportTextures = true; + ImportSounds = true; + ForceAllObjectsVisible = false; + OverrideVisualMaterial = null; + } + } +} \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs index 5705c4aa1..d9f5d899f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs @@ -148,18 +148,34 @@ public GameObject Convert(bool applyPatch = true, string tableName = null) _tableComponent.LegacyContainer = ScriptableObject.CreateInstance(); - ExtractPhysicsMaterials(); - ExtractTextures(); - ExtractSounds(); - SaveData(); - - var prefabLookup = InstantiateGameItems(); - var componentLookup = UpdateGameItems(prefabLookup); - - SaveLegacyData(); // now we freed the binary data, write the remaining game items. - FinalizeGameItems(componentLookup); - - FreeTextures(); + ExtractPhysicsMaterials(); + if (_options.ImportTextures && _options.OverrideVisualMaterial == null) { + ExtractTextures(); + } + if (_options.ImportSounds) { + ExtractSounds(); + } + SaveData(); + + var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; + var previousUseColliderGeometryForRampMeshes = ImportContext.UseColliderGeometryForRampMeshes; + ImportContext.SkipSurfaceParenting = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + ImportContext.UseColliderGeometryForRampMeshes = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + + Dictionary componentLookup; + try { + var prefabLookup = InstantiateGameItems(); + componentLookup = UpdateGameItems(prefabLookup); + + SaveLegacyData(); // now we freed the binary data, write the remaining game items. + FinalizeGameItems(componentLookup); + + } finally { + ImportContext.SkipSurfaceParenting = previousSkipSurfaceParenting; + ImportContext.UseColliderGeometryForRampMeshes = previousUseColliderGeometryForRampMeshes; + } + + FreeTextures(); ConfigurePlayer(componentLookup); @@ -219,10 +235,10 @@ private void SaveLegacyData() /// components. /// /// A dictionary with lower-case names as key, and created prefabs as values. - private Dictionary InstantiateGameItems() - { - var prefabLookup = new Dictionary(); - var renderables = _sourceContainer.Renderables.ToArray(); + private Dictionary InstantiateGameItems() + { + var prefabLookup = new Dictionary(); + var renderables = GetRenderablesForImport().ToArray(); try { // pause asset database refreshing @@ -241,27 +257,66 @@ private Dictionary InstantiateGameItems() AssetDatabase.Refresh(); } - return prefabLookup; - } - + return prefabLookup; + } + + private IEnumerable GetRenderablesForImport() + { + if (_options.ObjectImportFilter != VpxObjectImportFilter.CollidableOnly) { + return _sourceContainer.Renderables; + } + return _sourceContainer.Renderables.Where(ShouldImportCollidableObject); + } + + private bool ShouldImportCollidableObject(IRenderable renderable) + { + return IsPhysicsLoopObject(renderable); + } + + private static bool IsPhysicsLoopObject(IRenderable renderable) + { + switch (renderable) { + case Bumper _: + case Flipper _: + case Gate _: + case HitTarget _: + case Kicker _: + case Plunger _: + case Ramp _: + case Rubber _: + case Spinner _: + case Surface _: + case Trigger _: + case MetalWireGuide _: + return true; + case Primitive primitive: + return !primitive.Data.IsToy; + default: + return false; + } + } + /// /// In a second pass, we update the referenced data. This is so states dependent on other components /// is correctly applied. /// /// A dictionary with lower-case names as key, and created prefabs as values. - private Dictionary UpdateGameItems(Dictionary prefabLookup) - { - var componentLookup = prefabLookup.ToDictionary(x => x.Key, x => x.Value.MainComponent); - try { - // pause asset database refreshing - AssetDatabase.StartAssetEditing(); + private Dictionary UpdateGameItems(Dictionary prefabLookup) + { + var componentLookup = prefabLookup.ToDictionary(x => x.Key, x => x.Value.MainComponent); + try { + // pause asset database refreshing + AssetDatabase.StartAssetEditing(); var fxbExporter = new FxbExporter(); // first loop: write fbx files - foreach (var prefab in prefabLookup.Values) { - prefab.SetReferencedData(_sourceTable, this, this, componentLookup); - prefab.FreeBinaryData(); + foreach (var prefab in prefabLookup.Values) { + prefab.SetReferencedData(_sourceTable, this, this, componentLookup); + if (_options.ForceAllObjectsVisible && prefab.GameObject) { + ForceVisible(prefab.GameObject); + } + prefab.FreeBinaryData(); if (prefab.ExtractMesh) { var meshFilename = $"{prefab.GameObject.name.ToFilename()}.fbx"; @@ -277,11 +332,11 @@ private Dictionary UpdateGameItems(Dictionary UpdateGameItems(Dictionary componentLookup) - { - // convert non-renderables - foreach (var item in _sourceContainer.NonRenderables) { + private void FinalizeGameItems(Dictionary componentLookup) + { + // convert non-renderables + foreach (var item in _sourceContainer.NonRenderables) { var prefab = InstantiateAndParentPrefab(item); prefab.SetData(); prefab.SetReferencedData(_sourceTable, this, this, componentLookup); prefab.FreeBinaryData(); } - // the playfield needs separate treatment - _playfieldComponent.SetReferencedData(_sourceTable.Data, _sourceTable, this, this, null); + // the playfield needs separate treatment + _playfieldComponent.SetReferencedData(_sourceTable.Data, _sourceTable, this, this, null); + if (_options.ForceAllObjectsVisible && _playfieldGo) { + ForceVisible(_playfieldGo); + } // yes, really, persist changes.. EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); @@ -655,23 +713,29 @@ private string GetMeshPath(string parentName, string name) #region ITextureProvider - public Texture GetTexture(string name) - { - if (!_textures.ContainsKey(name.ToLower())) { - throw new ArgumentException($"Texture \"{name.ToLower()}\" not loaded!"); - } - return _textures[name.ToLower()]; - } + public Texture GetTexture(string name) + { + if (!_textures.ContainsKey(name.ToLower())) { + if (!_options.ImportTextures || _options.OverrideVisualMaterial != null) { + return null; + } + throw new ArgumentException($"Texture \"{name.ToLower()}\" not loaded!"); + } + return _textures[name.ToLower()]; + } #endregion #region IMaterialProvider - public bool HasMaterial(PbrMaterial material) - { - if (_materials.ContainsKey(material.Id)) { - return true; - } + public bool HasMaterial(PbrMaterial material) + { + if (_options.OverrideVisualMaterial != null) { + return true; + } + if (_materials.ContainsKey(material.Id)) { + return true; + } var path = material.GetUnityFilename(_assetsMaterials); if (File.Exists(path)) { _materials[material.Id] = AssetDatabase.LoadAssetAtPath(path); @@ -680,11 +744,14 @@ public bool HasMaterial(PbrMaterial material) return false; } - public Material GetMaterial(PbrMaterial material) - { - if (_materials.ContainsKey(material.Id)) { - return _materials[material.Id]; - } + public Material GetMaterial(PbrMaterial material) + { + if (_options.OverrideVisualMaterial != null) { + return _options.OverrideVisualMaterial; + } + if (_materials.ContainsKey(material.Id)) { + return _materials[material.Id]; + } var path = material.GetUnityFilename(_assetsMaterials); if (File.Exists(path)) { _materials[material.Id] = AssetDatabase.LoadAssetAtPath(path); @@ -712,34 +779,62 @@ public PhysicsMaterialAsset GetPhysicsMaterial(string name) return null; } - public Material MergeMaterials(string vpxMaterial, Material textureMaterial) - { - var pbrMaterial = new PbrMaterial(_sourceTable.GetMaterial(vpxMaterial), id: $"{vpxMaterial.ToNormalizedName()} __textured"); - return pbrMaterial.ToUnityMaterial(this, textureMaterial); - } - - public void SaveMaterial(PbrMaterial vpxMaterial, Material material) - { - _materials[vpxMaterial.Id] = material; - var path = vpxMaterial.GetUnityFilename(_assetsMaterials); - if (_options.SkipExistingMaterials && File.Exists(path)) { + public Material MergeMaterials(string vpxMaterial, Material textureMaterial) + { + if (_options.OverrideVisualMaterial != null) { + return _options.OverrideVisualMaterial; + } + var pbrMaterial = new PbrMaterial(_sourceTable.GetMaterial(vpxMaterial), id: $"{vpxMaterial.ToNormalizedName()} __textured"); + return pbrMaterial.ToUnityMaterial(this, textureMaterial); + } + + public void SaveMaterial(PbrMaterial vpxMaterial, Material material) + { + if (_options.OverrideVisualMaterial != null) { + _materials[vpxMaterial.Id] = _options.OverrideVisualMaterial; + return; + } + _materials[vpxMaterial.Id] = material; + var path = vpxMaterial.GetUnityFilename(_assetsMaterials); + if (_options.SkipExistingMaterials && File.Exists(path)) { return; } AssetDatabase.CreateAsset(material, path); } - #endregion - } - - public class ConvertOptions - { - public bool SkipExistingTextures = true; - public bool SkipExistingSounds = true; - public bool SkipExistingMaterials = true; - public bool SkipExistingMeshes = true; - - public static readonly ConvertOptions SkipNone = new ConvertOptions - { + #endregion + + private static void ForceVisible(GameObject root) + { + foreach (var transform in root.GetComponentsInChildren(true)) { + transform.gameObject.SetActive(true); + } + foreach (var renderer in root.GetComponentsInChildren(true)) { + renderer.enabled = true; + } + } + } + + public enum VpxObjectImportFilter + { + All, + CollidableOnly + } + + public class ConvertOptions + { + public bool SkipExistingTextures = true; + public bool SkipExistingSounds = true; + public bool SkipExistingMaterials = true; + public bool SkipExistingMeshes = true; + public bool ImportTextures = true; + public bool ImportSounds = true; + public bool ForceAllObjectsVisible = false; + public VpxObjectImportFilter ObjectImportFilter = VpxObjectImportFilter.All; + public Material OverrideVisualMaterial; + + public static readonly ConvertOptions SkipNone = new ConvertOptions + { SkipExistingMaterials = false, SkipExistingMeshes = false, SkipExistingSounds = false, diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/AssetReferenceLocator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/AssetReferenceLocator.cs index 49a22d018..63c02a8e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/AssetReferenceLocator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/AssetReferenceLocator.cs @@ -201,8 +201,8 @@ private void FindReferences() int total = candidateArray.Length; int hits = 0; - for (int i = 0; i < total; i++) - { + for (int i = 0; i < total; i++) + { string path = candidateArray[i]; if (EditorUtility.DisplayCancelableProgressBar("Scanning Assets + org.visualpinball packages", path, (float)i / total)) @@ -270,14 +270,18 @@ private void FindReferences() result.contexts.Add("[YAML] " + ln); } - _results.Add(result); - addedPaths.Add(path); - hits++; - } - - if (guidMode && string.IsNullOrEmpty(targetPath) && - EditorSettings.serializationMode != SerializationMode.ForceText) - { + _results.Add(result); + addedPaths.Add(path); + hits++; + } + + _results = _results + .OrderBy(r => r.assetPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (guidMode && string.IsNullOrEmpty(targetPath) && + EditorSettings.serializationMode != SerializationMode.ForceText) + { _status = $"Found {hits} direct references via GUID text search. Note: project serialization is {EditorSettings.serializationMode}; ForceText is recommended for reliable GUID scanning."; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs new file mode 100644 index 000000000..bc91a000c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs @@ -0,0 +1,34 @@ +// Visual Pinball Engine +// Copyright (C) 2023 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 +{ + /// + /// Global import context used by runtime components during editor-driven table conversion. + /// + public static class ImportContext + { + /// + /// If true, game items skip parenting to named VPX surfaces during import. + /// + public static bool SkipSurfaceParenting; + + /// + /// If true, visual ramp meshes use collision geometry parameters during import. + /// + public static bool UseColliderGeometryForRampMeshes; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs.meta new file mode 100644 index 000000000..bb1d70055 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4dd2fda9bbcc2d549b38a61b8484779b \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/MainRenderableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/MainRenderableComponent.cs index 3af86ccf8..b7d7d9c21 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/MainRenderableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/MainRenderableComponent.cs @@ -120,11 +120,14 @@ protected bool GetMeshVisibility() return false; } - protected void ParentToSurface(string surfaceName, Vertex2D center, Dictionary components) - { - if (!string.IsNullOrEmpty(surfaceName)) { - var surface = FindComponent(components, surfaceName); - if (surface == null) { + protected void ParentToSurface(string surfaceName, Vertex2D center, Dictionary components) + { + if (ImportContext.SkipSurfaceParenting) { + return; + } + if (!string.IsNullOrEmpty(surfaceName)) { + var surface = FindComponent(components, surfaceName); + if (surface == null) { Logger.Error($"Could not find surface {surfaceName} to parent to."); return; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs index c9db05c7a..e8ad6497b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs @@ -118,7 +118,16 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac #endregion - public bool IsWireRamp => _type != RampType.RampTypeFlat; + public bool IsWireRamp => _type != RampType.RampTypeFlat; + + internal IRampData GetMeshGenerationData() + { + if (!ImportContext.UseColliderGeometryForRampMeshes) { + return this; + } + var colliderComponent = GetComponentInChildren(); + return colliderComponent ? new CollisionGeometryRampData(this, colliderComponent) : this; + } #region Overrides @@ -227,9 +236,9 @@ public override void UpdateVisibility() #region Conversion - public override IEnumerable SetData(RampData data) - { - var updatedComponents = new List { this }; + public override IEnumerable SetData(RampData data) + { + var updatedComponents = new List { this }; // geometry DragPoints = data.DragPoints; @@ -245,40 +254,14 @@ public override IEnumerable SetData(RampData data) _imageAlignment = data.ImageAlignment; // wire data - _wireDiameter = data.WireDiameter; - _wireDistanceX = data.WireDistanceX; - _wireDistanceY = data.WireDistanceY; - - // visibility and mesh creation - var wallComponent = GetComponentInChildren(true); - var floorComponent = GetComponentInChildren(true); - var wireComponent = GetComponentInChildren(true); - if (IsWireRamp) { - if (wireComponent) { - wireComponent.gameObject.SetActive(data.IsVisible); - } - if (floorComponent) { - floorComponent.gameObject.SetActive(false); - } - if (wallComponent) { - wallComponent.gameObject.SetActive(false); - } - } else { - if (wireComponent) { - wireComponent.gameObject.SetActive(false); - } - if (floorComponent) { - floorComponent.gameObject.SetActive(data.IsVisible); - } - if (wallComponent) { - wallComponent.gameObject.SetActive(data.IsVisible && (_leftWallHeightVisible > 0 || _rightWallHeightVisible > 0)); - } - } - - // collider data - var collComponent = GetComponentInChildren(); - if (collComponent) { - collComponent.enabled = data.IsCollidable; + _wireDiameter = data.WireDiameter; + _wireDistanceX = data.WireDistanceX; + _wireDistanceY = data.WireDistanceY; + + // collider data + var collComponent = GetComponentInChildren(); + if (collComponent) { + collComponent.enabled = data.IsCollidable; collComponent.Elasticity = data.Elasticity; collComponent.Friction = data.Friction; collComponent.HitEvent = data.HitEvent; @@ -288,14 +271,46 @@ public override IEnumerable SetData(RampData data) collComponent.LeftWallHeight = data.LeftWallHeight; collComponent.RightWallHeight = data.RightWallHeight; - - updatedComponents.Add(collComponent); - } - - CenterPivot(); - - return updatedComponents; - } + + updatedComponents.Add(collComponent); + } + + if (ImportContext.UseColliderGeometryForRampMeshes && collComponent) { + var wallHeights = GetCollisionWallHeights(_type, collComponent.RightWallHeight, collComponent.LeftWallHeight); + _rightWallHeightVisible = wallHeights.rightWallHeight; + _leftWallHeightVisible = wallHeights.leftWallHeight; + } + + // visibility and mesh creation + var wallComponent = GetComponentInChildren(true); + var floorComponent = GetComponentInChildren(true); + var wireComponent = GetComponentInChildren(true); + if (IsWireRamp) { + if (wireComponent) { + wireComponent.gameObject.SetActive(data.IsVisible); + } + if (floorComponent) { + floorComponent.gameObject.SetActive(false); + } + if (wallComponent) { + wallComponent.gameObject.SetActive(false); + } + } else { + if (wireComponent) { + wireComponent.gameObject.SetActive(false); + } + if (floorComponent) { + floorComponent.gameObject.SetActive(data.IsVisible); + } + if (wallComponent) { + wallComponent.gameObject.SetActive(data.IsVisible && (_leftWallHeightVisible > 0 || _rightWallHeightVisible > 0)); + } + } + + CenterPivot(); + + return updatedComponents; + } public override IEnumerable SetReferencedData(RampData data, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider, Dictionary components) { @@ -398,8 +413,8 @@ public override void CopyFromObject(GameObject go) RebuildMeshes(); } - private void CenterPivot() - { + private void CenterPivot() + { var centerVpx = DragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); centerVpx /= DragPoints.Length; @@ -411,10 +426,56 @@ private void CenterPivot() foreach (var dragPoint in DragPoints) { dragPoint.Center -= centerVpx.ToVertex3D(); } - RebuildMeshes(); - } - - #endregion - - } -} + RebuildMeshes(); + } + + internal static (float rightWallHeight, float leftWallHeight) GetCollisionWallHeights(int type, float flatRightWallHeight, float flatLeftWallHeight) + { + switch (type) { + case RampType.RampTypeFlat: return (flatRightWallHeight, flatLeftWallHeight); + case RampType.RampType1Wire: return (31.0f, 31.0f); + case RampType.RampType2Wire: return (31.0f, 31.0f); + case RampType.RampType4Wire: return (62.0f, 62.0f); + case RampType.RampType3WireRight: return (62.0f, 18.5f); + case RampType.RampType3WireLeft: return (18.5f, 62.0f); + default: return (flatRightWallHeight, flatLeftWallHeight); + } + } + + #endregion + + } + + internal class CollisionGeometryRampData : IRampData + { + public float RightWallHeightVisible { get; } + public float LeftWallHeightVisible { get; } + public int Type { get; } + public float WireDistanceX { get; } + public float WireDistanceY { get; } + public int ImageAlignment { get; } + public float WireDiameter { get; } + public float HeightBottom { get; } + public float HeightTop { get; } + public float WidthTop { get; } + public float WidthBottom { get; } + public DragPointData[] DragPoints { get; } + + public CollisionGeometryRampData(IRampData source, RampColliderComponent colliderComponent) + { + Type = source.Type; + WireDistanceX = source.WireDistanceX; + WireDistanceY = source.WireDistanceY; + ImageAlignment = source.ImageAlignment; + WireDiameter = source.WireDiameter; + HeightBottom = source.HeightBottom; + HeightTop = source.HeightTop; + WidthTop = source.WidthTop; + WidthBottom = source.WidthBottom; + DragPoints = source.DragPoints; + var wallHeights = RampComponent.GetCollisionWallHeights(Type, colliderComponent.RightWallHeight, colliderComponent.LeftWallHeight); + RightWallHeightVisible = wallHeights.rightWallHeight; + LeftWallHeightVisible = wallHeights.leftWallHeight; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampFloorMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampFloorMeshComponent.cs index 1f6ad58fa..e3a02eab1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampFloorMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampFloorMeshComponent.cs @@ -28,13 +28,13 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mesh/Ramp Floor Mesh")] public class RampFloorMeshComponent : MeshComponent { - protected override Mesh GetMesh(RampData _) - { - var playfieldComponent = GetComponentInParent(); - return new RampMeshGenerator(MainComponent, MainComponent.uvOffset.ToVertex3D()) - .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Floor) - .TransformToWorld(); - } + protected override Mesh GetMesh(RampData _) + { + var playfieldComponent = GetComponentInParent(); + return new RampMeshGenerator(MainComponent.GetMeshGenerationData(), MainComponent.uvOffset.ToVertex3D()) + .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Floor) + .TransformToWorld(); + } protected override PbrMaterial GetMaterial(RampData data, Table table) => new RampMeshGenerator(MainComponent, Vertex3D.Zero).GetMaterial(table, data); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWallMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWallMeshComponent.cs index 2e8364ddb..a9aec1a16 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWallMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWallMeshComponent.cs @@ -28,13 +28,13 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mesh/Ramp Wall Mesh")] public class RampWallMeshComponent : MeshComponent { - protected override Mesh GetMesh(RampData data) - { - var playfieldComponent = GetComponentInParent(); - return new RampMeshGenerator(MainComponent, MainComponent.uvOffset.ToVertex3D()) - .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Wall) - .TransformToWorld(); - } + protected override Mesh GetMesh(RampData data) + { + var playfieldComponent = GetComponentInParent(); + return new RampMeshGenerator(MainComponent.GetMeshGenerationData(), MainComponent.uvOffset.ToVertex3D()) + .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Wall) + .TransformToWorld(); + } protected override PbrMaterial GetMaterial(RampData data, Table table) => new RampMeshGenerator(MainComponent, Vertex3D.Zero).GetMaterial(table, data); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWireMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWireMeshComponent.cs index f7bd57d76..f75be63e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWireMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampWireMeshComponent.cs @@ -28,13 +28,13 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mesh/Ramp Wire Mesh")] public class RampWireMeshComponent : MeshComponent { - protected override Mesh GetMesh(RampData data) - { - var playfieldComponent = GetComponentInParent(); - return new RampMeshGenerator(MainComponent, MainComponent.uvOffset.ToVertex3D()) - .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Wires) - .TransformToWorld(); - } + protected override Mesh GetMesh(RampData data) + { + var playfieldComponent = GetComponentInParent(); + return new RampMeshGenerator(MainComponent.GetMeshGenerationData(), MainComponent.uvOffset.ToVertex3D()) + .GetMesh(playfieldComponent.Width, playfieldComponent.Height, 0, RampMeshGenerator.Wires) + .TransformToWorld(); + } protected override PbrMaterial GetMaterial(RampData data, Table table) => new RampMeshGenerator(MainComponent, Vertex3D.Zero).GetMaterial(table, data); From c763442c9f9a29f588363c5850a7ec70e3915965 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 17 Apr 2026 00:52:28 +0200 Subject: [PATCH 05/14] unity: Material version updates. --- .../Assets/Resources/Materials/Dot Matrix Display (SRP).mat | 2 +- .../Assets/Resources/Materials/Segment Display (SRP).mat | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/Assets/Resources/Materials/Dot Matrix Display (SRP).mat b/VisualPinball.Unity/Assets/Resources/Materials/Dot Matrix Display (SRP).mat index cb87fca25..34922501d 100644 --- a/VisualPinball.Unity/Assets/Resources/Materials/Dot Matrix Display (SRP).mat +++ b/VisualPinball.Unity/Assets/Resources/Materials/Dot Matrix Display (SRP).mat @@ -328,4 +328,4 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} m_Name: m_EditorClassIdentifier: - version: 9 + version: 10 diff --git a/VisualPinball.Unity/Assets/Resources/Materials/Segment Display (SRP).mat b/VisualPinball.Unity/Assets/Resources/Materials/Segment Display (SRP).mat index f53e3bc1c..e82351a0c 100644 --- a/VisualPinball.Unity/Assets/Resources/Materials/Segment Display (SRP).mat +++ b/VisualPinball.Unity/Assets/Resources/Materials/Segment Display (SRP).mat @@ -331,4 +331,4 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} m_Name: m_EditorClassIdentifier: - version: 9 + version: 10 From d5757fd723885ea6276e6aef07ee6a8cee750def Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 17 Apr 2026 00:55:39 +0200 Subject: [PATCH 06/14] plugins: Add meta for linux nativeinput. --- .gitignore | 1 + .../Documentation~/developer-guide/setup.md | 87 ++++++++++--------- ...nput.so.meta => libVpeNativeInput.so.meta} | 39 +++++---- 3 files changed, 69 insertions(+), 58 deletions(-) rename VisualPinball.Unity/Plugins/linux-x64/{libVisualPinball.NativeInput.so.meta => libVpeNativeInput.so.meta} (57%) diff --git a/.gitignore b/.gitignore index aca8b892d..d9b2f189d 100644 --- a/.gitignore +++ b/.gitignore @@ -391,3 +391,4 @@ VisualPinball.Unity/VisualPinball.Unity.Test/TestProject~/editmode-results.xml .DS_Store +AGENTS.md \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/developer-guide/setup.md b/VisualPinball.Unity/Documentation~/developer-guide/setup.md index 655e3cdfd..6c91f529b 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/setup.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/setup.md @@ -91,51 +91,61 @@ The workaround is to tell git to explicitly ignore those files. You only do that Show all assume-unchanged commands ```bash +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/ICSharpCode.SharpZipLib.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/JeremyAnsel.Media.WavefrontObj.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NLog.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NetMiniZ.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.Extensions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/System.Buffers.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/VisualPinball.Resources.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/ICSharpCode.SharpZipLib.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/JeremyAnsel.Media.WavefrontObj.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NLog.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NetMiniZ.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.Extensions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/System.Buffers.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/VisualPinball.Resources.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/ICSharpCode.SharpZipLib.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/JeremyAnsel.Media.WavefrontObj.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/NLog.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/NetMiniZ.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.Extensions.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/System.Buffers.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/VisualPinball.Resources.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/libminiz.so.2.2.0.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/libvips.so.42.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/linux-x64/OpenMcdf.Extensions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/arm64/libvips.42.dylib.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/x64/libvips.42.dylib.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/ICSharpCode.SharpZipLib.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/JeremyAnsel.Media.WavefrontObj.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/NLog.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/NetMiniZ.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/OpenMcdf.Extensions.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/OpenMcdf.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/System.Buffers.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/VisualPinball.Resources.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/arm64/libVisualPinball.NativeInput.dylib.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/arm64/libvips.42.dylib.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/libminiz.2.2.0.dylib.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/libvips.42.dylib.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/OpenMcdf.Extensions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/FluentAssertions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/JeremyAnsel.Media.WavefrontObj.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NLog.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NetMiniZ.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NetVips.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/OpenMcdf.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/System.Buffers.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/VisualPinball.Resources.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libglib-2.0-0.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libgobject-2.0-0.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libminiz-2.2.0.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libvips-42.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/OpenMcdf.Extensions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/x64/libVisualPinball.NativeInput.dylib.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/osx/x64/libvips.42.dylib.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/ICSharpCode.SharpZipLib.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/JeremyAnsel.Media.WavefrontObj.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/NLog.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/NetMiniZ.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/OpenMcdf.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/System.Buffers.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/VisualPinball.Resources.dll.meta @@ -143,28 +153,21 @@ git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/libglib- git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/libgobject-2.0-0.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/libminiz-2.2.0.dll.meta git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/libvips-42.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x64/OpenMcdf.Extensions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/FluentAssertions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/JeremyAnsel.Media.WavefrontObj.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NLog.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NetMiniZ.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/NetVips.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/System.Buffers.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/VisualPinball.Resources.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/android-arm64-v8a/OpenMcdf.Extensions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/FluentAssertions.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/JeremyAnsel.Media.WavefrontObj.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NLog.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NetMiniZ.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/NetVips.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/System.Buffers.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/VisualPinball.Resources.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/ICSharpCode.SharpZipLib.dll.meta -git update-index --assume-unchanged VisualPinball.Unity/Plugins/ios-arm64/OpenMcdf.Extensions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/FluentAssertions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/ICSharpCode.SharpZipLib.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/JeremyAnsel.Media.WavefrontObj.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NLog.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NetMiniZ.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/NetVips.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/OpenMcdf.Extensions.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/OpenMcdf.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/System.Buffers.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/VisualPinball.NativeInput.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/VisualPinball.Resources.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libglib-2.0-0.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libgobject-2.0-0.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libminiz-2.2.0.dll.meta +git update-index --assume-unchanged VisualPinball.Unity/Plugins/win-x86/libvips-42.dll.meta ``` Yeah, we know... \ No newline at end of file diff --git a/VisualPinball.Unity/Plugins/linux-x64/libVisualPinball.NativeInput.so.meta b/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta similarity index 57% rename from VisualPinball.Unity/Plugins/linux-x64/libVisualPinball.NativeInput.so.meta rename to VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta index ab40b7731..303fd34ca 100644 --- a/VisualPinball.Unity/Plugins/linux-x64/libVisualPinball.NativeInput.so.meta +++ b/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta @@ -1,8 +1,8 @@ fileFormatVersion: 2 -guid: b02a8a2827304b85b2f4ae5f26ebf5ea +guid: 379a0325bb540cf48bc9c2aeaada5593 PluginImporter: externalObjects: {} - serializedVersion: 2 + serializedVersion: 3 iconMap: {} executionOrder: {} defineConstraints: [] @@ -11,33 +11,40 @@ PluginImporter: isExplicitlyReferenced: 0 validateReferences: 1 platformData: - - first: - : Any - second: + Android: enabled: 0 + settings: + Is16KbAligned: false + Any: + enabled: 1 settings: Exclude Editor: 0 - Exclude Android: 1 Exclude Linux64: 0 Exclude OSXUniversal: 1 - Exclude Win: 1 - Exclude Win64: 1 - Exclude iOS: 1 - Exclude tvOS: 1 - - first: - Editor: Editor - second: + Exclude Win: 0 + Exclude Win64: 0 + Editor: enabled: 1 settings: CPU: x86_64 DefaultValueInitialized: true OS: Linux - - first: - Standalone: Linux64 - second: + Linux64: enabled: 1 settings: CPU: x86_64 + OSXUniversal: + enabled: 0 + settings: + CPU: None + Win: + enabled: 1 + settings: + CPU: x86 + Win64: + enabled: 1 + settings: + CPU: None userData: assetBundleName: assetBundleVariant: From 28345b1005a07de620f5207f6bd6011099fc0e6d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 17 Apr 2026 01:35:57 +0200 Subject: [PATCH 07/14] fix: Batch activation in lamp manager. --- .../Managers/Lamp/LampManager.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs index 03b2a4f54..2f4eb9b19 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Managers/Lamp/LampManager.cs @@ -183,20 +183,29 @@ private void ToggleLampState(bool enabled) } if (!handledByApi) { - SetLampDeviceEnabled(device, enabled); + SetLampDeviceEnabled(device, enabled, Application.isPlaying); } } } - private static void SetLampDeviceEnabled(ILampDeviceComponent device, bool enabled) + private static void SetLampDeviceEnabled(ILampDeviceComponent device, bool enabled, bool isPlaying) { + // In edit mode LightComponent runtime caches are not initialized yet, + // so directly toggling Unity lights keeps the manager buttons responsive. + if (!isPlaying) { + foreach (var light in device.LightSources.Where(light => light != null)) { + light.enabled = enabled; + } + return; + } + switch (device) { case LightComponent lightComponent: lightComponent.Enabled = enabled; break; case LightGroupComponent lightGroup: foreach (var child in lightGroup.Lights.Where(child => child != null)) { - SetLampDeviceEnabled(child, enabled); + SetLampDeviceEnabled(child, enabled, isPlaying); } break; default: From f1134445a1dddd5cc38e2d91ece43c3c20261cde Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 18 Apr 2026 11:10:32 +0200 Subject: [PATCH 08/14] import: Fix uvs when generating playfield mesh. --- .../VPT/Table/TableMeshGenerator.cs | 11 ++-- .../VPT/Playfield/PlayfieldComponent.cs | 65 +++++++++++++------ 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/VisualPinball.Engine/VPT/Table/TableMeshGenerator.cs b/VisualPinball.Engine/VPT/Table/TableMeshGenerator.cs index 6a80a2953..b75b9b807 100644 --- a/VisualPinball.Engine/VPT/Table/TableMeshGenerator.cs +++ b/VisualPinball.Engine/VPT/Table/TableMeshGenerator.cs @@ -63,11 +63,12 @@ private Mesh GetFromTableDimensions(bool asRightHanded) new Vertex3DNoTex2(_data.Right, _data.Bottom, _data.TableHeight), new Vertex3DNoTex2(_data.Left, _data.Bottom, _data.TableHeight), }; - var mesh = new Mesh { - Name = _data.Name, - Vertices = rgv.Select(r => new Vertex3DNoTex2()).ToArray(), - Indices = new [] { 0, 1, 3, 0, 3, 2 } - }; + var mesh = new Mesh { + Name = _data.Name, + Vertices = rgv.Select(r => new Vertex3DNoTex2()).ToArray(), + // Keep the same triangulation as VPX's implicit playfield mesh. + Indices = new[] { 0, 1, 2, 2, 1, 3 } + }; for (var i = 0; i < 4; ++i) { rgv[i].Nx = 0; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldComponent.cs index 0c5c83228..1957e303a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Playfield/PlayfieldComponent.cs @@ -161,31 +161,54 @@ public override IEnumerable SetReferencedData(TableData data, Tab return Array.Empty(); } - public IEnumerable SetReferencedData(PrimitiveData primitiveData, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider) - { - var mf = GetComponent(); - var playfieldMeshComponent = GetComponent(); - if (!mf || !playfieldMeshComponent) { - return Array.Empty(); - } - - var updatedComponents = new List { this }; - var mg = new PrimitiveMeshGenerator(primitiveData); - var mesh = mg - .GetTransformedMesh(0, primitiveData.Mesh, Origin.Original, false) - .Transform(mg.TransformationMatrix(0)) // apply transformation to mesh, because this is the playfield - .TransformToWorld(); // also, transform this to world space. - var material = new PbrMaterial( - table?.GetMaterial(_playfieldMaterial), - table?.GetTexture(_playfieldImage) - ); - MeshComponent.CreateMesh(gameObject, mesh, material, "playfield_mesh", textureProvider, materialProvider); + public IEnumerable SetReferencedData(PrimitiveData primitiveData, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider) + { + var mf = GetComponent(); + var playfieldMeshComponent = GetComponent(); + if (!mf || !playfieldMeshComponent) { + return Array.Empty(); + } + + var updatedComponents = new List { this }; + var mg = new PrimitiveMeshGenerator(primitiveData); + var mesh = mg + .GetTransformedMesh(0, primitiveData.Mesh, Origin.Original, false) + .Transform(mg.TransformationMatrix(0)); // apply transformation to mesh, because this is the playfield + ApplyPlayfieldUvProjection(mesh, table?.Data); + mesh.TransformToWorld(); // also, transform this to world space. + var material = new PbrMaterial( + table?.GetMaterial(_playfieldMaterial), + table?.GetTexture(_playfieldImage) + ); + MeshComponent.CreateMesh(gameObject, mesh, material, "playfield_mesh", textureProvider, materialProvider); playfieldMeshComponent.AutoGenerate = false; updatedComponents.Add(playfieldMeshComponent); - return updatedComponents; - } + return updatedComponents; + } + + private static void ApplyPlayfieldUvProjection(Engine.VPT.Mesh mesh, TableData tableData) + { + if (mesh?.Vertices == null || tableData == null) { + return; + } + + var width = tableData.Right - tableData.Left; + var height = tableData.Bottom - tableData.Top; + if (math.abs(width) < math.EPSILON || math.abs(height) < math.EPSILON) { + return; + } + + var invWidth = 1f / width; + var invHeight = 1f / height; + for (var i = 0; i < mesh.Vertices.Length; i++) { + var vertex = mesh.Vertices[i]; + vertex.Tu = (vertex.X - tableData.Left) * invWidth; + vertex.Tv = (vertex.Y - tableData.Top) * invHeight; + mesh.Vertices[i] = vertex; + } + } public override TableData CopyDataTo(TableData data, string[] materialNames, string[] textureNames, bool forExport) { From 41e3d7bb27ac4a74bd745bbcd151656717ea331d Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 18 Apr 2026 11:12:49 +0200 Subject: [PATCH 09/14] import: Add options to dump table script and render bumpers as colliders. --- .../Import/VpxImportWizard.cs | 16 +- .../Import/VpxImportWizardSettings.cs | 20 +- .../Import/VpxSceneConverter.cs | 462 +++++++++++------- .../Import/ImportContext.cs | 5 + .../VPT/Bumper/BumperComponent.cs | 212 +++++++- 5 files changed, 498 insertions(+), 217 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs index 20bbb2ee9..14fdc9314 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizard.cs @@ -173,6 +173,11 @@ public void OnGUI() GUILayout.Space(settingsMargin); + VpxImportWizardSettings.DumpTableScript = EditorGUILayout.Toggle("Dump Table Script", VpxImportWizardSettings.DumpTableScript); + EditorGUILayout.LabelField("Writes the VPX table script to Assets/Tables//
.vbs during import.", labelInfoStyle); + + GUILayout.Space(settingsMargin); + VpxImportWizardSettings.ForceAllObjectsVisible = EditorGUILayout.Toggle("Force All Visible", VpxImportWizardSettings.ForceAllObjectsVisible); EditorGUILayout.LabelField("Forces imported objects and child meshes to be visible, ignoring VPX visibility flags.", labelInfoStyle); @@ -182,10 +187,19 @@ public void OnGUI() EditorGUILayout.LabelField("If set, all imported renderers use this material and no visual materials or texture assets are created.", labelInfoStyle); + GUILayout.Space(settingsMargin); + + using (new EditorGUI.DisabledScope(VpxImportWizardSettings.OverrideVisualMaterial == null)) { + VpxImportWizardSettings.AlwaysImportPlayfieldMaterial = EditorGUILayout.Toggle("Always Import Playfield Material", VpxImportWizardSettings.AlwaysImportPlayfieldMaterial); + } + EditorGUILayout.LabelField(VpxImportWizardSettings.OverrideVisualMaterial == null + ? "Requires Override Material." + : "If enabled, keeps VPX playfield material+image import and does not apply the override material to the playfield mesh.", labelInfoStyle); + GUILayout.FlexibleSpace(); } - EditorGUILayout.EndVertical(); + EditorGUILayout.EndVertical(); #endregion Settings #region Bottom Toolbar diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs index 2a2c32b1c..fde182a55 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs @@ -61,6 +61,12 @@ public static bool ImportSounds set => EditorPrefs.SetBool("ImportSounds", value); } + public static bool DumpTableScript + { + get => EditorPrefs.GetBool("DumpTableScript", false); + set => EditorPrefs.SetBool("DumpTableScript", value); + } + public static bool ForceAllObjectsVisible { get => EditorPrefs.GetBool("ForceAllObjectsVisible", false); @@ -83,14 +89,22 @@ public static Material OverrideVisualMaterial } } + public static bool AlwaysImportPlayfieldMaterial + { + get => EditorPrefs.GetBool("AlwaysImportPlayfieldMaterial", false); + set => EditorPrefs.SetBool("AlwaysImportPlayfieldMaterial", value); + } + public static ConvertOptions BuildConvertOptions() { var options = new ConvertOptions { ObjectImportFilter = ObjectImportFilter, ImportTextures = ImportTextures, ImportSounds = ImportSounds, + DumpTableScript = DumpTableScript, ForceAllObjectsVisible = ForceAllObjectsVisible, - OverrideVisualMaterial = OverrideVisualMaterial + OverrideVisualMaterial = OverrideVisualMaterial, + AlwaysImportPlayfieldMaterial = AlwaysImportPlayfieldMaterial }; if (options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly) { @@ -115,8 +129,10 @@ public static void Reset() ObjectImportFilter = VpxObjectImportFilter.All; ImportTextures = true; ImportSounds = true; + DumpTableScript = false; ForceAllObjectsVisible = false; OverrideVisualMaterial = null; + AlwaysImportPlayfieldMaterial = false; } } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs index d9f5d899f..a215376c2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs @@ -88,6 +88,8 @@ public class VpxSceneConverter : ITextureProvider, IMaterialProvider, IMeshProvi private readonly IPatcher _patcher; private bool _applyPatch = true; + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + /// /// Creates a new converter for a new table /// @@ -139,58 +141,96 @@ public VpxSceneConverter(TableComponent tableComponent) CreateFileHierarchy(); } - public GameObject Convert(bool applyPatch = true, string tableName = null) - { - _applyPatch = applyPatch; + public GameObject Convert(bool applyPatch = true, string tableName = null) + { + _applyPatch = applyPatch; + + CreateRootHierarchy(tableName); + CreateFileHierarchy(); + DumpTableScript(); + + _tableComponent.LegacyContainer = ScriptableObject.CreateInstance(); - CreateRootHierarchy(tableName); - CreateFileHierarchy(); + ExtractPhysicsMaterials(); + if (_options.OverrideVisualMaterial == null) { + if (_options.ImportTextures) { + ExtractTextures(); + } + } else if (_options.AlwaysImportPlayfieldMaterial) { + ExtractPlayfieldTexture(); + } + if (_options.ImportSounds) { + ExtractSounds(); + } + SaveData(); + + var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; + var previousUseColliderGeometryForRampMeshes = ImportContext.UseColliderGeometryForRampMeshes; + var previousUseColliderGeometryForBumperMeshes = ImportContext.UseColliderGeometryForBumperMeshes; + ImportContext.SkipSurfaceParenting = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + ImportContext.UseColliderGeometryForRampMeshes = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + ImportContext.UseColliderGeometryForBumperMeshes = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; - _tableComponent.LegacyContainer = ScriptableObject.CreateInstance(); + Dictionary componentLookup; + try { + var prefabLookup = InstantiateGameItems(); + componentLookup = UpdateGameItems(prefabLookup); - ExtractPhysicsMaterials(); - if (_options.ImportTextures && _options.OverrideVisualMaterial == null) { - ExtractTextures(); + SaveLegacyData(); // now we freed the binary data, write the remaining game items. + FinalizeGameItems(componentLookup); + + } finally { + ImportContext.SkipSurfaceParenting = previousSkipSurfaceParenting; + ImportContext.UseColliderGeometryForRampMeshes = previousUseColliderGeometryForRampMeshes; + ImportContext.UseColliderGeometryForBumperMeshes = previousUseColliderGeometryForBumperMeshes; + } + + FreeTextures(); + + ConfigurePlayer(componentLookup); + + SetUpAudio(); + + // patch + if (_applyPatch) { + _patcher?.PostPatch(_tableGo); } - if (_options.ImportSounds) { - ExtractSounds(); + + ApplyPlayfieldVisualMaterial(); + + return _tableGo; + } + + private void ApplyPlayfieldVisualMaterial() + { + if (!_options.AlwaysImportPlayfieldMaterial || _options.OverrideVisualMaterial == null || !_playfieldGo) { + return; } - SaveData(); - var previousSkipSurfaceParenting = ImportContext.SkipSurfaceParenting; - var previousUseColliderGeometryForRampMeshes = ImportContext.UseColliderGeometryForRampMeshes; - ImportContext.SkipSurfaceParenting = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; - ImportContext.UseColliderGeometryForRampMeshes = _options.ObjectImportFilter == VpxObjectImportFilter.CollidableOnly; + var meshRenderer = _playfieldGo.GetComponent(); + if (!meshRenderer) { + return; + } - Dictionary componentLookup; + var previousOverride = _options.OverrideVisualMaterial; + _options.OverrideVisualMaterial = null; try { - var prefabLookup = InstantiateGameItems(); - componentLookup = UpdateGameItems(prefabLookup); - - SaveLegacyData(); // now we freed the binary data, write the remaining game items. - FinalizeGameItems(componentLookup); + var playfieldMaterial = new PbrMaterial( + _sourceTable.GetMaterial(_sourceTable.Data.PlayfieldMaterial), + _sourceTable.GetTexture(_sourceTable.Data.Image) + ) { + MaterialType = MaterialType.Standard + }; + meshRenderer.sharedMaterial = playfieldMaterial.ToUnityMaterial(this, this); } finally { - ImportContext.SkipSurfaceParenting = previousSkipSurfaceParenting; - ImportContext.UseColliderGeometryForRampMeshes = previousUseColliderGeometryForRampMeshes; + _options.OverrideVisualMaterial = previousOverride; } + } - FreeTextures(); - - ConfigurePlayer(componentLookup); - - SetUpAudio(); - - // patch - if (_applyPatch) { - _patcher?.PostPatch(_tableGo); - } - - return _tableGo; - } - private void SaveData() - { - foreach (var key in _sourceContainer.TableInfo.Keys) { + private void SaveData() + { + foreach (var key in _sourceContainer.TableInfo.Keys) { _tableComponent.TableInfo[key] = _sourceContainer.TableInfo[key]; } _tableComponent.CustomInfoTags = _sourceContainer.CustomInfoTags; @@ -235,10 +275,10 @@ private void SaveLegacyData() /// components. /// /// A dictionary with lower-case names as key, and created prefabs as values. - private Dictionary InstantiateGameItems() - { - var prefabLookup = new Dictionary(); - var renderables = GetRenderablesForImport().ToArray(); + private Dictionary InstantiateGameItems() + { + var prefabLookup = new Dictionary(); + var renderables = GetRenderablesForImport().ToArray(); try { // pause asset database refreshing @@ -257,66 +297,66 @@ private Dictionary InstantiateGameItems() AssetDatabase.Refresh(); } - return prefabLookup; - } - - private IEnumerable GetRenderablesForImport() - { - if (_options.ObjectImportFilter != VpxObjectImportFilter.CollidableOnly) { - return _sourceContainer.Renderables; - } - return _sourceContainer.Renderables.Where(ShouldImportCollidableObject); - } - - private bool ShouldImportCollidableObject(IRenderable renderable) - { - return IsPhysicsLoopObject(renderable); - } - - private static bool IsPhysicsLoopObject(IRenderable renderable) - { - switch (renderable) { - case Bumper _: - case Flipper _: - case Gate _: - case HitTarget _: - case Kicker _: - case Plunger _: - case Ramp _: - case Rubber _: - case Spinner _: - case Surface _: - case Trigger _: - case MetalWireGuide _: - return true; - case Primitive primitive: - return !primitive.Data.IsToy; - default: - return false; - } - } - + return prefabLookup; + } + + private IEnumerable GetRenderablesForImport() + { + if (_options.ObjectImportFilter != VpxObjectImportFilter.CollidableOnly) { + return _sourceContainer.Renderables; + } + return _sourceContainer.Renderables.Where(ShouldImportCollidableObject); + } + + private bool ShouldImportCollidableObject(IRenderable renderable) + { + return IsPhysicsLoopObject(renderable); + } + + private static bool IsPhysicsLoopObject(IRenderable renderable) + { + switch (renderable) { + case Bumper _: + case Flipper _: + case Gate _: + case HitTarget _: + case Kicker _: + case Plunger _: + case Ramp _: + case Rubber _: + case Spinner _: + case Surface _: + case Trigger _: + case MetalWireGuide _: + return true; + case Primitive primitive: + return !primitive.Data.IsToy; + default: + return false; + } + } + /// /// In a second pass, we update the referenced data. This is so states dependent on other components /// is correctly applied. /// /// A dictionary with lower-case names as key, and created prefabs as values. - private Dictionary UpdateGameItems(Dictionary prefabLookup) - { - var componentLookup = prefabLookup.ToDictionary(x => x.Key, x => x.Value.MainComponent); - try { - // pause asset database refreshing - AssetDatabase.StartAssetEditing(); + private Dictionary UpdateGameItems(Dictionary prefabLookup) + { + var componentLookup = prefabLookup.ToDictionary(x => x.Key, x => x.Value.MainComponent); + try { + // pause asset database refreshing + AssetDatabase.StartAssetEditing(); var fxbExporter = new FxbExporter(); // first loop: write fbx files - foreach (var prefab in prefabLookup.Values) { - prefab.SetReferencedData(_sourceTable, this, this, componentLookup); - if (_options.ForceAllObjectsVisible && prefab.GameObject) { - ForceVisible(prefab.GameObject); - } - prefab.FreeBinaryData(); + foreach (var prefab in prefabLookup.Values) { + prefab.SetReferencedData(_sourceTable, this, this, componentLookup); + if (_options.ForceAllObjectsVisible && prefab.GameObject) { + ForceVisible(prefab.GameObject); + } + prefab.FreeBinaryData(); if (prefab.ExtractMesh) { var meshFilename = $"{prefab.GameObject.name.ToFilename()}.fbx"; @@ -332,11 +372,11 @@ private Dictionary UpdateGameItems(Dictionary UpdateGameItems(Dictionary componentLookup) - { - // convert non-renderables - foreach (var item in _sourceContainer.NonRenderables) { + private void FinalizeGameItems(Dictionary componentLookup) + { + // convert non-renderables + foreach (var item in _sourceContainer.NonRenderables) { var prefab = InstantiateAndParentPrefab(item); prefab.SetData(); prefab.SetReferencedData(_sourceTable, this, this, componentLookup); prefab.FreeBinaryData(); } - // the playfield needs separate treatment - _playfieldComponent.SetReferencedData(_sourceTable.Data, _sourceTable, this, this, null); - if (_options.ForceAllObjectsVisible && _playfieldGo) { - ForceVisible(_playfieldGo); - } + // the playfield needs separate treatment + var previousOverrideMaterial = _options.OverrideVisualMaterial; + if (_options.AlwaysImportPlayfieldMaterial) { + _options.OverrideVisualMaterial = null; + } + try { + _playfieldComponent.SetReferencedData(_sourceTable.Data, _sourceTable, this, this, null); + } finally { + _options.OverrideVisualMaterial = previousOverrideMaterial; + } + if (_options.ForceAllObjectsVisible && _playfieldGo) { + ForceVisible(_playfieldGo); + } // yes, really, persist changes.. EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); @@ -483,11 +531,38 @@ private string SavePhysicsMaterial(Engine.VPT.Material material) private void ExtractTextures() { + ExtractTextures(_sourceContainer.Textures); + } + + private void ExtractPlayfieldTexture() + { + var playfieldImage = _sourceTable.Data.Image; + if (string.IsNullOrEmpty(playfieldImage)) { + return; + } + + var playfieldTexture = _sourceContainer.Textures.FirstOrDefault(texture => + string.Equals(texture.Name, playfieldImage, StringComparison.CurrentCultureIgnoreCase)); + if (playfieldTexture == null) { + Logger.Warn($"Could not find playfield image texture \"{playfieldImage}\" in VPX texture list."); + return; + } + + ExtractTextures(new[] { playfieldTexture }); + } + + private void ExtractTextures(IEnumerable textures) + { + var textureList = textures?.ToList() ?? new List(); + if (textureList.Count == 0) { + return; + } + try { // pause asset database refreshing AssetDatabase.StartAssetEditing(); - foreach (var texture in _sourceContainer.Textures) { + foreach (var texture in textureList) { texture.WriteAsAsset(_assetsTextures, _options.SkipExistingTextures); } @@ -498,7 +573,7 @@ private void ExtractTextures() } // now they are in the asset database, we can load them. - foreach (var texture in _sourceContainer.Textures) { + foreach (var texture in textureList) { var path = texture.GetUnityFilename(_assetsTextures, texture.IsWebp ? ".png" : null); var unityTexture = texture.IsHdr ? (Texture)AssetDatabase.LoadAssetAtPath(path) ?? AssetDatabase.LoadAssetAtPath(path) @@ -589,11 +664,11 @@ private void SetUpAudio() _tableGo.AddComponent(); } - private void CreateFileHierarchy() - { - if (!Directory.Exists("Assets/Tables/")) { - Directory.CreateDirectory("Assets/Tables/"); - } + private void CreateFileHierarchy() + { + if (!Directory.Exists("Assets/Tables/")) { + Directory.CreateDirectory("Assets/Tables/"); + } _assetsTableRoot = $"Assets/Tables/{_tableGo.name}/"; if (!Directory.Exists(_assetsTableRoot)) { @@ -621,10 +696,21 @@ private void CreateFileHierarchy() } _assetsSounds = $"{_assetsTableRoot}Sounds/"; - if (!Directory.Exists(_assetsSounds)) { - Directory.CreateDirectory(_assetsSounds); - } - } + if (!Directory.Exists(_assetsSounds)) { + Directory.CreateDirectory(_assetsSounds); + } + } + + private void DumpTableScript() + { + if (!_options.DumpTableScript) { + return; + } + + var scriptPath = $"{_assetsTableRoot}{_tableGo.name.ToFilename()}.vbs"; + File.WriteAllText(scriptPath, _sourceTable.Data.Code ?? string.Empty); + AssetDatabase.ImportAsset(scriptPath, ImportAssetOptions.ForceUpdate); + } private void CreateRootHierarchy(string tableName = null) { @@ -713,29 +799,29 @@ private string GetMeshPath(string parentName, string name) #region ITextureProvider - public Texture GetTexture(string name) - { - if (!_textures.ContainsKey(name.ToLower())) { - if (!_options.ImportTextures || _options.OverrideVisualMaterial != null) { - return null; - } - throw new ArgumentException($"Texture \"{name.ToLower()}\" not loaded!"); - } - return _textures[name.ToLower()]; - } + public Texture GetTexture(string name) + { + if (!_textures.ContainsKey(name.ToLower())) { + if (!_options.ImportTextures || _options.OverrideVisualMaterial != null) { + return null; + } + throw new ArgumentException($"Texture \"{name.ToLower()}\" not loaded!"); + } + return _textures[name.ToLower()]; + } #endregion #region IMaterialProvider - public bool HasMaterial(PbrMaterial material) - { - if (_options.OverrideVisualMaterial != null) { - return true; - } - if (_materials.ContainsKey(material.Id)) { - return true; - } + public bool HasMaterial(PbrMaterial material) + { + if (_options.OverrideVisualMaterial != null) { + return true; + } + if (_materials.ContainsKey(material.Id)) { + return true; + } var path = material.GetUnityFilename(_assetsMaterials); if (File.Exists(path)) { _materials[material.Id] = AssetDatabase.LoadAssetAtPath(path); @@ -744,14 +830,14 @@ public bool HasMaterial(PbrMaterial material) return false; } - public Material GetMaterial(PbrMaterial material) - { - if (_options.OverrideVisualMaterial != null) { - return _options.OverrideVisualMaterial; - } - if (_materials.ContainsKey(material.Id)) { - return _materials[material.Id]; - } + public Material GetMaterial(PbrMaterial material) + { + if (_options.OverrideVisualMaterial != null) { + return _options.OverrideVisualMaterial; + } + if (_materials.ContainsKey(material.Id)) { + return _materials[material.Id]; + } var path = material.GetUnityFilename(_assetsMaterials); if (File.Exists(path)) { _materials[material.Id] = AssetDatabase.LoadAssetAtPath(path); @@ -779,48 +865,48 @@ public PhysicsMaterialAsset GetPhysicsMaterial(string name) return null; } - public Material MergeMaterials(string vpxMaterial, Material textureMaterial) - { - if (_options.OverrideVisualMaterial != null) { - return _options.OverrideVisualMaterial; - } - var pbrMaterial = new PbrMaterial(_sourceTable.GetMaterial(vpxMaterial), id: $"{vpxMaterial.ToNormalizedName()} __textured"); - return pbrMaterial.ToUnityMaterial(this, textureMaterial); - } - - public void SaveMaterial(PbrMaterial vpxMaterial, Material material) - { - if (_options.OverrideVisualMaterial != null) { - _materials[vpxMaterial.Id] = _options.OverrideVisualMaterial; - return; - } - _materials[vpxMaterial.Id] = material; - var path = vpxMaterial.GetUnityFilename(_assetsMaterials); - if (_options.SkipExistingMaterials && File.Exists(path)) { + public Material MergeMaterials(string vpxMaterial, Material textureMaterial) + { + if (_options.OverrideVisualMaterial != null) { + return _options.OverrideVisualMaterial; + } + var pbrMaterial = new PbrMaterial(_sourceTable.GetMaterial(vpxMaterial), id: $"{vpxMaterial.ToNormalizedName()} __textured"); + return pbrMaterial.ToUnityMaterial(this, textureMaterial); + } + + public void SaveMaterial(PbrMaterial vpxMaterial, Material material) + { + if (_options.OverrideVisualMaterial != null) { + _materials[vpxMaterial.Id] = _options.OverrideVisualMaterial; + return; + } + _materials[vpxMaterial.Id] = material; + var path = vpxMaterial.GetUnityFilename(_assetsMaterials); + if (_options.SkipExistingMaterials && File.Exists(path)) { return; } AssetDatabase.CreateAsset(material, path); } - #endregion - - private static void ForceVisible(GameObject root) - { - foreach (var transform in root.GetComponentsInChildren(true)) { - transform.gameObject.SetActive(true); - } - foreach (var renderer in root.GetComponentsInChildren(true)) { - renderer.enabled = true; - } - } - } - - public enum VpxObjectImportFilter - { - All, - CollidableOnly - } - + #endregion + + private static void ForceVisible(GameObject root) + { + foreach (var transform in root.GetComponentsInChildren(true)) { + transform.gameObject.SetActive(true); + } + foreach (var renderer in root.GetComponentsInChildren(true)) { + renderer.enabled = true; + } + } + } + + public enum VpxObjectImportFilter + { + All, + CollidableOnly + } + public class ConvertOptions { public bool SkipExistingTextures = true; @@ -829,12 +915,14 @@ public class ConvertOptions public bool SkipExistingMeshes = true; public bool ImportTextures = true; public bool ImportSounds = true; + public bool DumpTableScript = false; public bool ForceAllObjectsVisible = false; public VpxObjectImportFilter ObjectImportFilter = VpxObjectImportFilter.All; public Material OverrideVisualMaterial; - - public static readonly ConvertOptions SkipNone = new ConvertOptions - { + public bool AlwaysImportPlayfieldMaterial = false; + + public static readonly ConvertOptions SkipNone = new ConvertOptions + { SkipExistingMaterials = false, SkipExistingMeshes = false, SkipExistingSounds = false, @@ -906,4 +994,4 @@ public void Export(Mesh mesh, string path) Object.DestroyImmediate(go); } } -} +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs index bc91a000c..87acf2b4e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Import/ImportContext.cs @@ -30,5 +30,10 @@ public static class ImportContext /// If true, visual ramp meshes use collision geometry parameters during import. /// public static bool UseColliderGeometryForRampMeshes; + + /// + /// If true, bumper visuals are replaced by generated collider-cylinder meshes during import. + /// + public static bool UseColliderGeometryForBumperMeshes; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperComponent.cs index eb99557bd..8ec0f85d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Bumper/BumperComponent.cs @@ -25,12 +25,14 @@ using System.Collections.Generic; using System.Linq; using Unity.Mathematics; -using UnityEngine; -using VisualPinball.Engine.Game.Engines; -using VisualPinball.Engine.Math; -using VisualPinball.Engine.VPT; -using VisualPinball.Engine.VPT.Bumper; -using VisualPinball.Engine.VPT.Table; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Engine.Math; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Bumper; +using VisualPinball.Engine.VPT.Table; +using Material = UnityEngine.Material; +using Mesh = UnityEngine.Mesh; namespace VisualPinball.Unity { @@ -93,7 +95,9 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac protected override Type MeshComponentType { get; } = typeof(MeshComponent); protected override Type ColliderComponentType { get; } = typeof(ColliderComponent); - public const float DataMeshScale = 100f; + public const float DataMeshScale = 100f; + private const string ColliderVisualName = "__BumperColliderVisual"; + private static Mesh _colliderCylinderMesh; public const string SocketSwitchItem = "socket_switch"; public const string RingCoilItem = "ring_coil"; @@ -203,21 +207,35 @@ public override IEnumerable SetData(BumperData data) return updatedComponents; } - public override IEnumerable SetReferencedData(BumperData data, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider, Dictionary components) - { - // surface - ParentToSurface(data.Surface, data.Center, components); - - UpdateTransforms(); - - // children visibility - SetVisibilityByComponent(data.IsSocketVisible); - SetVisibilityByComponent(data.IsBaseVisible); - SetVisibilityByComponent(data.IsCapVisible); - SetVisibilityByComponent(data.IsRingVisible); - - return Array.Empty(); - } + public override IEnumerable SetReferencedData(BumperData data, Table table, IMaterialProvider materialProvider, ITextureProvider textureProvider, Dictionary components) + { + // surface + ParentToSurface(data.Surface, data.Center, components); + + UpdateTransforms(); + +#if UNITY_EDITOR + if (ImportContext.UseColliderGeometryForBumperMeshes) { + ApplyColliderOnlyVisual(ResolveColliderVisualMaterial(data, table, materialProvider)); + } else { + RemoveColliderOnlyVisual(); + + // children visibility + SetVisibilityByComponent(data.IsSocketVisible); + SetVisibilityByComponent(data.IsBaseVisible); + SetVisibilityByComponent(data.IsCapVisible); + SetVisibilityByComponent(data.IsRingVisible); + } +#else + // children visibility + SetVisibilityByComponent(data.IsSocketVisible); + SetVisibilityByComponent(data.IsBaseVisible); + SetVisibilityByComponent(data.IsCapVisible); + SetVisibilityByComponent(data.IsRingVisible); +#endif + + return Array.Empty(); + } public override BumperData CopyDataTo(BumperData data, string[] materialNames, string[] textureNames, bool forExport) { @@ -268,8 +286,8 @@ private bool CopyMaterialName(BumperData data, string[] materialName return false; } - public override void CopyFromObject(GameObject go) - { + public override void CopyFromObject(GameObject go) + { // main component var srcMainComp = go.GetComponent(); if (srcMainComp) { @@ -304,9 +322,149 @@ public override void CopyFromObject(GameObject go) if (ringAnimComp && srcSkirtAnimComp) { skirtAnimComp.duration = srcSkirtAnimComp.duration; } - } - - #endregion + } + +#if UNITY_EDITOR + private static Material ResolveColliderVisualMaterial(BumperData data, Table table, IMaterialProvider materialProvider) + { + if (materialProvider == null) { + return null; + } + + var names = new[] { data.BaseMaterial, data.CapMaterial, data.RingMaterial, data.SocketMaterial }; + foreach (var name in names) { + if (string.IsNullOrEmpty(name)) { + continue; + } + var vpxMaterial = table?.GetMaterial(name); + if (vpxMaterial == null) { + continue; + } + var material = materialProvider.GetMaterial(new PbrMaterial(vpxMaterial)); + if (material) { + return material; + } + } + + // Import override material is resolved in the provider, independent of the PBR payload. + return materialProvider.GetMaterial(new PbrMaterial()); + } + + private void ApplyColliderOnlyVisual(Material preferredMaterial) + { + var sourceMaterial = preferredMaterial ? preferredMaterial : GetComponentsInChildren(true) + .Select(renderer => renderer.sharedMaterial) + .FirstOrDefault(material => material != null); + + var colliderVisualGo = EnsureColliderVisual(sourceMaterial); + + foreach (var meshFilter in GetComponentsInChildren(true)) { + if (meshFilter.gameObject != colliderVisualGo) { + meshFilter.sharedMesh = null; + } + } + + foreach (var renderer in GetComponentsInChildren(true)) { + renderer.enabled = renderer.gameObject == colliderVisualGo; + } + } + + private GameObject EnsureColliderVisual(Material material) + { + var colliderVisualTransform = transform.Find(ColliderVisualName); + var colliderVisualGo = colliderVisualTransform + ? colliderVisualTransform.gameObject + : new GameObject(ColliderVisualName); + + if (!colliderVisualTransform) { + colliderVisualGo.transform.SetParent(transform, false); + } + + colliderVisualGo.transform.localPosition = Vector3.zero; + colliderVisualGo.transform.localRotation = Quaternion.identity; + colliderVisualGo.transform.localScale = Vector3.one; + colliderVisualGo.SetActive(true); + + var meshFilter = colliderVisualGo.GetComponent(); + if (!meshFilter) { + meshFilter = colliderVisualGo.AddComponent(); + } + meshFilter.sharedMesh = GetColliderCylinderMesh(); + + var meshRenderer = colliderVisualGo.GetComponent(); + if (!meshRenderer) { + meshRenderer = colliderVisualGo.AddComponent(); + } + if (material) { + meshRenderer.sharedMaterial = material; + } + meshRenderer.enabled = true; + + return colliderVisualGo; + } + + private void RemoveColliderOnlyVisual() + { + var colliderVisualTransform = transform.Find(ColliderVisualName); + if (colliderVisualTransform) { + DestroyImmediate(colliderVisualTransform.gameObject); + } + } + + private static Mesh GetColliderCylinderMesh() + { + if (_colliderCylinderMesh) { + return _colliderCylinderMesh; + } + + const int numSides = 32; + const float radius = DataMeshScale * 0.5f; + const float zLow = 0f; + const float zHigh = 100f; + + var vertices = new Vector3[numSides * 2]; + var normals = new Vector3[numSides * 2]; + var triangles = new int[numSides * 6]; + + for (var side = 0; side < numSides; side++) { + var angle = 2f * Mathf.PI * side / numSides; + var vpxX = Mathf.Cos(angle) * radius; + var vpxY = Mathf.Sin(angle) * radius; + var normal = new Vector3(vpxX, 0f, -vpxY).normalized; + var topVertexIndex = side * 2; + var bottomVertexIndex = topVertexIndex + 1; + + vertices[topVertexIndex] = new Vector3(vpxX, vpxY, zHigh).TranslateToWorld(); + vertices[bottomVertexIndex] = new Vector3(vpxX, vpxY, zLow).TranslateToWorld(); + normals[topVertexIndex] = normal; + normals[bottomVertexIndex] = normal; + + var nextSide = (side + 1) % numSides; + var nextTopVertexIndex = nextSide * 2; + var nextBottomVertexIndex = nextTopVertexIndex + 1; + var triangleIndex = side * 6; + + triangles[triangleIndex + 0] = topVertexIndex; + triangles[triangleIndex + 1] = bottomVertexIndex; + triangles[triangleIndex + 2] = nextBottomVertexIndex; + triangles[triangleIndex + 3] = topVertexIndex; + triangles[triangleIndex + 4] = nextBottomVertexIndex; + triangles[triangleIndex + 5] = nextTopVertexIndex; + } + + _colliderCylinderMesh = new Mesh { + name = "Bumper Collider Cylinder", + vertices = vertices, + normals = normals, + triangles = triangles + }; + _colliderCylinderMesh.RecalculateBounds(); + + return _colliderCylinderMesh; + } +#endif + + #endregion #region State From c16e349ddf20a6f0dd140e54897a21743538f4c0 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 18 Apr 2026 14:04:58 +0200 Subject: [PATCH 10/14] debug: Add physics render mask that only shows physics-relevant components. --- .../Game/PhysicsRenderMaskComponent.cs | 689 ++++++++++++++++++ .../Game/PhysicsRenderMaskComponent.cs.meta | 2 + 2 files changed, 691 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs new file mode 100644 index 000000000..281afafc4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs @@ -0,0 +1,689 @@ +// 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 . + +#if UNITY_EDITOR +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game; +using VisualPinball.Engine.VPT.Flipper; + +namespace VisualPinball.Unity +{ + [AddComponentMenu("Pinball/Debug/Physics Render Mask")] + [DisallowMultipleComponent] + public class PhysicsRenderMaskComponent : MonoBehaviour + { + [Tooltip("Master toggle for this debug rendering pass.")] + public bool IsEnabled = true; + + [Tooltip("Include inactive children when collecting renderers/colliders.")] + public bool IncludeInactiveChildren = true; + + [Tooltip("Objects in this list (and their children) are left untouched and render as usual.")] + public List RenderAsUsualObjects = new(); + + [Header("Per-Game-Item Materials")] + public Material BallMaterial; + public Material BumperMaterial; + public Material DropTargetMaterial; + public Material FlipperMaterial; + public Material GateMaterial; + public Material HitTargetMaterial; + public Material KickerMaterial; + public Material MetalWireGuideMaterial; + public Material PlayfieldMaterial; + public Material PlungerMaterial; + public Material PrimitiveMaterial; + public Material RampMaterial; + public Material RubberMaterial; + public Material SpinnerMaterial; + public Material SurfaceMaterial; + public Material TriggerMaterial; + public Material UnknownGameItemMaterial; + + [Header("State Override Materials")] + public Material KinematicColliderMaterial; + public Material DisabledColliderMaterial; + + [NonSerialized] private readonly Dictionary _originalRendererStates = new(); + [NonSerialized] private readonly Dictionary _originalFilterMeshes = new(); + [NonSerialized] private readonly List _addedRenderers = new(); + [NonSerialized] private readonly List _addedFilters = new(); + [NonSerialized] private readonly List _generatedMeshes = new(); + [NonSerialized] private readonly Dictionary _runtimeColliderEnabledByItemId = new(); + + [NonSerialized] private PhysicsEngine _physicsEngine; + [NonSerialized] private bool _isApplied; + [NonSerialized] private bool _previousEnabledState; + + private enum ColliderRenderState + { + Normal, + Kinematic, + Disabled + } + + private enum GameItemType + { + Unknown, + Ball, + Bumper, + DropTarget, + Flipper, + Gate, + HitTarget, + Kicker, + MetalWireGuide, + Playfield, + Plunger, + Primitive, + Ramp, + Rubber, + Spinner, + Surface, + Trigger + } + + private readonly struct RendererState + { + public readonly bool Enabled; + public readonly Material[] Materials; + + public RendererState(bool enabled, Material[] materials) + { + Enabled = enabled; + Materials = materials; + } + } + + private sealed class MeshData + { + public readonly List Vertices = new(); + public readonly List Normals = new(); + public readonly List Indices = new(); + public bool HasGeometry => Vertices.Count > 0 && Indices.Count > 0; + } + + private IEnumerator Start() + { + _previousEnabledState = IsEnabled; + if (!IsEnabled) { + yield break; + } + + _physicsEngine ??= GetComponentInParent(); + var waitFrames = 0; + while (_physicsEngine != null && !_physicsEngine.IsInitialized && waitFrames < 300) { + waitFrames++; + yield return null; + } + + Apply(); + } + + private void Update() + { + if (_previousEnabledState == IsEnabled) { + return; + } + + _previousEnabledState = IsEnabled; + if (IsEnabled) { + Apply(); + } else { + Restore(); + } + } + + private void OnDisable() + { + if (_isApplied) { + Restore(); + } + } + + private void OnDestroy() + { + CleanupGeneratedArtifacts(); + } + + [ContextMenu("Apply")] + public void Apply() + { + if (_isApplied) { + Restore(); + } + + _physicsEngine ??= GetComponentInParent(); + if (_physicsEngine == null || !_physicsEngine.IsInitialized) { + Debug.LogWarning($"[{nameof(PhysicsRenderMaskComponent)}] PhysicsEngine not ready on \"{name}\"."); + return; + } + + _runtimeColliderEnabledByItemId.Clear(); + DisableOriginalRenderers(); + RenderColliderMeshesOnColliderObjects(); + _isApplied = true; + } + + [ContextMenu("Restore")] + public void Restore() + { + foreach (var rendererAndState in _originalRendererStates) { + var renderer = rendererAndState.Key; + if (!renderer) { + continue; + } + + var state = rendererAndState.Value; + renderer.enabled = state.Enabled; + renderer.sharedMaterials = state.Materials; + } + + foreach (var filterAndMesh in _originalFilterMeshes) { + if (filterAndMesh.Key) { + filterAndMesh.Key.sharedMesh = filterAndMesh.Value; + } + } + + _originalRendererStates.Clear(); + _originalFilterMeshes.Clear(); + CleanupGeneratedArtifacts(); + _isApplied = false; + } + + private void DisableOriginalRenderers() + { + var meshRenderers = GetComponentsInChildren(IncludeInactiveChildren); + foreach (var meshRenderer in meshRenderers) { + if (!meshRenderer) { + continue; + } + if (ShouldRenderAsUsual(meshRenderer.gameObject)) { + continue; + } + + if (!_originalRendererStates.ContainsKey(meshRenderer)) { + _originalRendererStates[meshRenderer] = new RendererState(meshRenderer.enabled, meshRenderer.sharedMaterials); + } + meshRenderer.enabled = false; + } + } + + private void RenderColliderMeshesOnColliderObjects() + { + var playfield = GetComponentInParent(); + var playfieldToWorld = playfield ? playfield.transform.localToWorldMatrix : Matrix4x4.identity; + var worldToPlayfield = playfield ? playfield.transform.worldToLocalMatrix : Matrix4x4.identity; + + var collidables = GetComponentsInChildren(IncludeInactiveChildren); + foreach (var collidable in collidables) { + if (collidable is not Behaviour behaviour) { + continue; + } + if (ShouldRenderAsUsual(behaviour.gameObject)) { + continue; + } + + var colliders = GetRuntimeColliders(collidable); + if (colliders.Length == 0) { + continue; + } + + var renderState = Classify(collidable, behaviour); + var gameItemType = ResolveGameItemType(behaviour); + var material = ResolveMaterial(renderState, gameItemType); + + var targetTransform = behaviour.transform; + var transformedToTarget = targetTransform.worldToLocalMatrix * playfieldToWorld * (Matrix4x4)Physics.VpxToWorld; + var untransformedToTarget = transformedToTarget * (Matrix4x4)collidable.GetLocalToPlayfieldMatrixInVpx(worldToPlayfield); + + var mesh = BuildColliderMesh(behaviour, colliders, transformedToTarget, untransformedToTarget); + if (mesh == null) { + continue; + } + + AssignMeshToColliderObject(behaviour.gameObject, mesh, material); + } + } + + private void AssignMeshToColliderObject(GameObject targetObject, Mesh mesh, Material material) + { + var meshFilter = targetObject.GetComponent(); + if (meshFilter == null) { + meshFilter = targetObject.AddComponent(); + _addedFilters.Add(meshFilter); + } else if (!_originalFilterMeshes.ContainsKey(meshFilter)) { + _originalFilterMeshes[meshFilter] = meshFilter.sharedMesh; + } + meshFilter.sharedMesh = mesh; + + var meshRenderer = targetObject.GetComponent(); + if (meshRenderer == null) { + meshRenderer = targetObject.AddComponent(); + _addedRenderers.Add(meshRenderer); + } else if (!_originalRendererStates.ContainsKey(meshRenderer)) { + _originalRendererStates[meshRenderer] = new RendererState(meshRenderer.enabled, meshRenderer.sharedMaterials); + } + + meshRenderer.sharedMaterial = material; + meshRenderer.enabled = true; + } + + private ICollider[] GetRuntimeColliders(ICollidableComponent collidable) + { + try { + return collidable.IsKinematic + ? _physicsEngine.GetKinematicColliders(collidable.ItemId) + : _physicsEngine.GetColliders(collidable.ItemId); + } catch { + return Array.Empty(); + } + } + + private ColliderRenderState Classify(ICollidableComponent collidable, Behaviour behaviour) + { + if (!behaviour.isActiveAndEnabled) { + return ColliderRenderState.Disabled; + } + + if (TryGetRuntimeColliderEnabled(collidable.ItemId, out var runtimeEnabled) && !runtimeEnabled) { + return ColliderRenderState.Disabled; + } + + if (collidable.IsKinematic) { + return ColliderRenderState.Kinematic; + } + + return ColliderRenderState.Normal; + } + + private bool TryGetRuntimeColliderEnabled(int itemId, out bool isEnabled) + { + if (_runtimeColliderEnabledByItemId.TryGetValue(itemId, out isEnabled)) { + return true; + } + + try { + isEnabled = _physicsEngine.IsColliderEnabled(itemId); + _runtimeColliderEnabledByItemId[itemId] = isEnabled; + return true; + } catch { + isEnabled = true; + return false; + } + } + + private static bool TryGetIsTransformed(ICollider collider, out bool isTransformed) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + var colliderType = collider.GetType(); + var headerField = colliderType.GetField("Header", flags); + if (headerField == null) { + isTransformed = true; + return false; + } + + var headerValue = headerField.GetValue(collider); + if (headerValue == null) { + isTransformed = true; + return false; + } + + var headerType = headerValue.GetType(); + var transformedField = headerType.GetField("IsTransformed", flags); + if (transformedField != null && transformedField.FieldType == typeof(bool)) { + isTransformed = (bool)transformedField.GetValue(headerValue); + return true; + } + + isTransformed = true; + return false; + } + + private static GameItemType ResolveGameItemType(Behaviour colliderBehaviour) + { + var typeName = colliderBehaviour.GetType().Name; + return typeName switch { + "BallColliderComponent" => GameItemType.Ball, + "BumperColliderComponent" => GameItemType.Bumper, + "DropTargetColliderComponent" => GameItemType.DropTarget, + "FlipperColliderComponent" => GameItemType.Flipper, + "GateColliderComponent" => GameItemType.Gate, + "HitTargetColliderComponent" => GameItemType.HitTarget, + "KickerColliderComponent" => GameItemType.Kicker, + "MetalWireGuideColliderComponent" => GameItemType.MetalWireGuide, + "PlayfieldColliderComponent" => GameItemType.Playfield, + "PlungerColliderComponent" => GameItemType.Plunger, + "PrimitiveColliderComponent" => GameItemType.Primitive, + "RampColliderComponent" => GameItemType.Ramp, + "RubberColliderComponent" => GameItemType.Rubber, + "SpinnerColliderComponent" => GameItemType.Spinner, + "SurfaceColliderComponent" => GameItemType.Surface, + "TriggerColliderComponent" => GameItemType.Trigger, + _ => GameItemType.Unknown + }; + } + + private Material ResolveMaterial(ColliderRenderState renderState, GameItemType gameItemType) + { + var baseMaterial = GetMaterialForGameItemType(gameItemType); + return renderState switch { + ColliderRenderState.Disabled => DisabledColliderMaterial ? DisabledColliderMaterial : baseMaterial, + ColliderRenderState.Kinematic => KinematicColliderMaterial ? KinematicColliderMaterial : baseMaterial, + _ => baseMaterial + }; + } + + private Material GetMaterialForGameItemType(GameItemType gameItemType) + { + return gameItemType switch { + GameItemType.Ball => BallMaterial, + GameItemType.Bumper => BumperMaterial, + GameItemType.DropTarget => DropTargetMaterial, + GameItemType.Flipper => FlipperMaterial, + GameItemType.Gate => GateMaterial, + GameItemType.HitTarget => HitTargetMaterial, + GameItemType.Kicker => KickerMaterial, + GameItemType.MetalWireGuide => MetalWireGuideMaterial, + GameItemType.Playfield => PlayfieldMaterial, + GameItemType.Plunger => PlungerMaterial, + GameItemType.Primitive => PrimitiveMaterial, + GameItemType.Ramp => RampMaterial, + GameItemType.Rubber => RubberMaterial, + GameItemType.Spinner => SpinnerMaterial, + GameItemType.Surface => SurfaceMaterial, + GameItemType.Trigger => TriggerMaterial, + _ => UnknownGameItemMaterial + }; + } + + private Mesh BuildColliderMesh(Behaviour sourceComponent, IReadOnlyList colliders, Matrix4x4 transformedMatrix, Matrix4x4 untransformedMatrix) + { + var transformed = new MeshData(); + var untransformed = new MeshData(); + + for (var i = 0; i < colliders.Count; i++) { + var collider = colliders[i]; + var target = TryGetIsTransformed(collider, out var isTransformed) && !isTransformed ? untransformed : transformed; + + switch (collider) { + case CircleCollider circleCollider: + AddCollider(circleCollider, target); + break; + case FlipperCollider: + AddFlipperCollider(sourceComponent, target, isTransformed ? Origin.Global : Origin.Original); + break; + case GateCollider gateCollider: + AddCollider(gateCollider.LineSeg0, target); + AddCollider(gateCollider.LineSeg1, target); + break; + case LineCollider lineCollider: + AddCollider(lineCollider, target); + break; + case LineSlingshotCollider lineSlingshotCollider: + AddCollider(lineSlingshotCollider, target); + break; + case LineZCollider lineZCollider: + AddCollider(lineZCollider, target); + break; + case PlungerCollider plungerCollider: + AddCollider(plungerCollider.LineSegBase, target); + AddCollider(plungerCollider.JointBase0, target); + AddCollider(plungerCollider.JointBase1, target); + break; + case SpinnerCollider spinnerCollider: + AddCollider(spinnerCollider.LineSeg0, target); + AddCollider(spinnerCollider.LineSeg1, target); + break; + case TriangleCollider triangleCollider: + AddCollider(triangleCollider, target); + break; + } + } + + if (!transformed.HasGeometry && !untransformed.HasGeometry) { + return null; + } + + var vertices = new List(transformed.Vertices.Count + untransformed.Vertices.Count); + var normals = new List(transformed.Normals.Count + untransformed.Normals.Count); + var indices = new List(transformed.Indices.Count + untransformed.Indices.Count); + + AppendMeshData(transformed, transformedMatrix, vertices, normals, indices); + AppendMeshData(untransformed, untransformedMatrix, vertices, normals, indices); + + var mesh = new Mesh { + name = $"{sourceComponent.name} (Physics Mask)" + }; + mesh.SetVertices(vertices); + mesh.SetNormals(normals); + mesh.SetTriangles(indices, 0, true); + mesh.RecalculateBounds(); + _generatedMeshes.Add(mesh); + return mesh; + } + + private static void AppendMeshData(MeshData source, Matrix4x4 matrix, ICollection vertices, ICollection normals, ICollection indices) + { + if (!source.HasGeometry) { + return; + } + + var baseIndex = vertices.Count; + for (var i = 0; i < source.Vertices.Count; i++) { + vertices.Add(matrix.MultiplyPoint3x4(source.Vertices[i])); + } + for (var i = 0; i < source.Normals.Count; i++) { + normals.Add(matrix.MultiplyVector(source.Normals[i]).normalized); + } + for (var i = 0; i < source.Indices.Count; i++) { + indices.Add(baseIndex + source.Indices[i]); + } + } + + private void CleanupGeneratedArtifacts() + { + for (var i = 0; i < _addedRenderers.Count; i++) { + if (_addedRenderers[i]) { + DestroyUnityObject(_addedRenderers[i]); + } + } + for (var i = 0; i < _addedFilters.Count; i++) { + if (_addedFilters[i]) { + DestroyUnityObject(_addedFilters[i]); + } + } + for (var i = 0; i < _generatedMeshes.Count; i++) { + if (_generatedMeshes[i]) { + DestroyUnityObject(_generatedMeshes[i]); + } + } + + _addedRenderers.Clear(); + _addedFilters.Clear(); + _generatedMeshes.Clear(); + } + + private bool ShouldRenderAsUsual(GameObject targetObject) + { + for (var i = 0; i < RenderAsUsualObjects.Count; i++) { + var keepObject = RenderAsUsualObjects[i]; + if (!keepObject) { + continue; + } + if (targetObject.transform.IsChildOf(keepObject.transform)) { + return true; + } + } + return false; + } + + private static void DestroyUnityObject(UnityEngine.Object obj) + { + if (!obj) { + return; + } + if (Application.isPlaying) { + Destroy(obj); + } else { + DestroyImmediate(obj); + } + } + + private static void AddCollider(CircleCollider circleCol, MeshData mesh) + { + var startIdx = mesh.Vertices.Count; + const int sides = 32; + const float angleStep = 360f / sides; + var rotation = Quaternion.Euler(0f, 0f, angleStep); + const int max = sides - 1; + var pos = new Vector3(circleCol.Center.x, circleCol.Center.y, 0); + + mesh.Vertices.Add(rotation * new Vector3(circleCol.Radius, 0f, circleCol.ZHigh) + pos); + mesh.Vertices.Add(rotation * new Vector3(circleCol.Radius, 0f, circleCol.ZLow) + pos); + mesh.Vertices.Add(rotation * (mesh.Vertices[mesh.Vertices.Count - 1] - pos) + pos); + mesh.Vertices.Add(rotation * (mesh.Vertices[mesh.Vertices.Count - 3] - pos) + pos); + + mesh.Indices.Add(startIdx + 0); + mesh.Indices.Add(startIdx + 1); + mesh.Indices.Add(startIdx + 2); + mesh.Indices.Add(startIdx + 0); + mesh.Indices.Add(startIdx + 2); + mesh.Indices.Add(startIdx + 3); + + mesh.Normals.Add((mesh.Vertices[startIdx] - pos).normalized); + mesh.Normals.Add(mesh.Normals[startIdx]); + mesh.Normals.Add(mesh.Normals[startIdx]); + mesh.Normals.Add(mesh.Normals[startIdx]); + + for (var i = 0; i < max; i++) { + mesh.Vertices.Add(rotation * (mesh.Vertices[mesh.Vertices.Count - 2] - pos) + pos); + mesh.Indices.Add(mesh.Vertices.Count - 1); + mesh.Indices.Add(mesh.Vertices.Count - 2); + mesh.Indices.Add(mesh.Vertices.Count - 3); + mesh.Normals.Add(rotation * mesh.Normals[mesh.Normals.Count - 1]); + + mesh.Vertices.Add(rotation * (mesh.Vertices[mesh.Vertices.Count - 2] - pos) + pos); + mesh.Indices.Add(mesh.Vertices.Count - 3); + mesh.Indices.Add(mesh.Vertices.Count - 2); + mesh.Indices.Add(mesh.Vertices.Count - 1); + mesh.Normals.Add(mesh.Normals[mesh.Normals.Count - 1]); + } + } + + private static void AddCollider(LineZCollider lineZCol, MeshData mesh) + { + const float width = 10f; + var bottom = new Vector3(lineZCol.XY.x, lineZCol.XY.y, lineZCol.ZLow); + var top = new Vector3(lineZCol.XY.x, lineZCol.XY.y, lineZCol.ZHigh); + + var i = mesh.Vertices.Count; + mesh.Vertices.Add(bottom + new Vector3(width, 0, 0)); + mesh.Vertices.Add(top + new Vector3(width, 0, 0)); + mesh.Vertices.Add(top + new Vector3(-width, 0, 0)); + mesh.Vertices.Add(bottom + new Vector3(-width, 0, 0)); + + var normal = Vector3.up; + mesh.Normals.Add(normal); + mesh.Normals.Add(normal); + mesh.Normals.Add(normal); + mesh.Normals.Add(normal); + + mesh.Indices.Add(i + 0); + mesh.Indices.Add(i + 2); + mesh.Indices.Add(i + 1); + mesh.Indices.Add(i + 3); + mesh.Indices.Add(i + 2); + mesh.Indices.Add(i + 0); + } + + private static void AddCollider(LineCollider lineCol, MeshData mesh) + { + AddCollider(lineCol.V1, lineCol.V2, new float3(lineCol.Normal.x, lineCol.Normal.y, 0f), lineCol.ZLow, lineCol.ZHigh, mesh); + } + + private static void AddCollider(LineSlingshotCollider lineCol, MeshData mesh) + { + AddCollider(lineCol.V1, lineCol.V2, new float3(lineCol.Normal.x, lineCol.Normal.y, 0f), lineCol.ZLow, lineCol.ZHigh, mesh); + } + + private static void AddCollider(float2 v1, float2 v2, float3 normal, float zLow, float zHigh, MeshData mesh) + { + var h = zHigh - zLow; + var i = mesh.Vertices.Count; + mesh.Vertices.Add(new Vector3(v1.x, v1.y, zLow)); + mesh.Vertices.Add(new Vector3(v1.x, v1.y, zLow + h)); + mesh.Vertices.Add(new Vector3(v2.x, v2.y, zLow)); + mesh.Vertices.Add(new Vector3(v2.x, v2.y, zLow + h)); + + var n = new Vector3(normal.x, normal.y, normal.z); + mesh.Normals.Add(n); + mesh.Normals.Add(n); + mesh.Normals.Add(n); + mesh.Normals.Add(n); + + mesh.Indices.Add(i + 0); + mesh.Indices.Add(i + 2); + mesh.Indices.Add(i + 1); + mesh.Indices.Add(i + 2); + mesh.Indices.Add(i + 3); + mesh.Indices.Add(i + 1); + } + + private static void AddCollider(TriangleCollider triangleCol, MeshData mesh) + { + var i = mesh.Vertices.Count; + mesh.Vertices.Add(new Vector3(triangleCol.Rgv0.x, triangleCol.Rgv0.y, triangleCol.Rgv0.z)); + mesh.Vertices.Add(new Vector3(triangleCol.Rgv1.x, triangleCol.Rgv1.y, triangleCol.Rgv1.z)); + mesh.Vertices.Add(new Vector3(triangleCol.Rgv2.x, triangleCol.Rgv2.y, triangleCol.Rgv2.z)); + + var n = triangleCol.Normal(); + var normal = new Vector3(n.x, n.y, n.z); + mesh.Normals.Add(normal); + mesh.Normals.Add(normal); + mesh.Normals.Add(normal); + + mesh.Indices.Add(i + 0); + mesh.Indices.Add(i + 2); + mesh.Indices.Add(i + 1); + } + + private static void AddFlipperCollider(Component sourceComponent, MeshData mesh, Origin origin) + { + var flipperComponent = sourceComponent.GetComponentInChildren(); + if (flipperComponent == null) { + return; + } + + var startIdx = mesh.Vertices.Count; + var flipperMesh = new FlipperMeshGenerator(flipperComponent).GetMesh(FlipperMeshGenerator.Rubber, 0, 0.01f, origin, false, 0.2f); + for (var i = 0; i < flipperMesh.Vertices.Length; i++) { + var vertex = flipperMesh.Vertices[i]; + mesh.Vertices.Add(vertex.ToUnityFloat3()); + mesh.Normals.Add(vertex.ToUnityNormalVector3()); + } + for (var i = 0; i < flipperMesh.Indices.Length; i++) { + mesh.Indices.Add(startIdx + flipperMesh.Indices[i]); + } + } + } +} +#endif diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs.meta new file mode 100644 index 000000000..15af221c6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsRenderMaskComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f631aa754f684f15b494a6526f2afdd4 \ No newline at end of file From 37f0565942b14a5fcbc4a51e426e8d2abbc6b299 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 18 Apr 2026 16:54:08 +0200 Subject: [PATCH 11/14] flippers: Align flipper tricks code (nfozzy/rothbauerw) to more modern implementations. --- .../VPT/Flipper/FlipperColliderInspector.cs | 26 ++++--- .../VPT/Flipper/FlipperCollider.cs | 71 +++++++++++-------- .../VPT/Flipper/FlipperColliderComponent.cs | 52 ++++++++------ .../VPT/Flipper/FlipperComponent.cs | 2 + .../VPT/Flipper/FlipperTricksData.cs | 2 + 5 files changed, 93 insertions(+), 60 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs index 11c51aa95..644af2c74 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Flipper/FlipperColliderInspector.cs @@ -65,6 +65,8 @@ public class FlipperColliderInspector : ColliderInspector tricks.LiveCatchDistanceMax) { + var liveDist = math.abs(ballPosition); + //Logger.Info("BallPosition = {0}", liveDist); + if (liveDist >= tricks.LiveCatchDistanceMax) { //Logger.Info("BallPosition = {0} -> no calculation", ballPosition); return; } - if (math.abs(ballPosition) < tricks.LiveCatchDistanceMin) { + if (liveDist <= tricks.LiveCatchDistanceMin) { //Logger.Info("BallPosition = {0} -> no calculation", ballPosition); return; } - // only test for LiveCatch if Ballspeed is greater as set Minimal Speed (default = 6) - // different to nFozzys implementation we calculate all speeds based on the angle of the flipper, not y direction. - if (normalSpeed >= tricks.LiveCatchMinimalBallSpeed) { - float catchTime = (float)(msec - tricks.FlipperAngleEndTime * 1000); - if (catchTime <= tricks.LiveCatchFullTime){ - // we have a live catch, so stop the ball for now. - ball.Velocity += normalSpeed * collEvent.HitNormal; - // do we have some bounce - // as a difference to the nFozzy implementation, we don't deal with hard-coded speeds, but multiplier to current speed against the flipper. - var liveCatchBounceMultiplier = tricks.LiveCatchMinimalBounceSpeedMultiplier; - //Logger.Info("We have a live catch"); - if (catchTime > tricks.LiveCatchPerfectTime) { - // but it's imperfect, so we have add some bounce - // example: hit after 10 msecs, fulltime is 16, perfect time is 8, should be (10-8)/(16-8)*inaccuracySpeedMultiplier - liveCatchBounceMultiplier = (catchTime - tricks.LiveCatchPerfectTime) / (tricks.LiveCatchFullTime - tricks.LiveCatchPerfectTime) * (tricks.LiveCatchInaccurateBounceSpeedMultiplier-tricks.LiveCatchMinimalBounceSpeedMultiplier) + tricks.LiveCatchMinimalBounceSpeedMultiplier; - } - //Logger.Info("Bounce Multiplicator is {0}, catchtime {1}", liveCatchBounceMultiplier, catchTime); - ball.Velocity -= collEvent.HitNormal * normalSpeed * liveCatchBounceMultiplier; - ball.AngularMomentum.x = 0; - ball.AngularMomentum.y = 0; + var impactSpeed = math.max(math.dot(collEvent.HitNormal, ball.Velocity) * -1f, -collEvent.HitOrgNormalVelocity); + if (impactSpeed <= tricks.LiveCatchMinimalBallSpeed) { + return; + } + + var catchTime = (float)(msec - tricks.FlipperAngleEndTime * 1000); + if (catchTime > tricks.LiveCatchFullTime) { + return; + } + + var flipperAxis = hitTangent; + if (ballPosition < 0f) { + flipperAxis = -flipperAxis; + } + var tangentSpeed = math.dot(ball.Velocity, flipperAxis); + var movingTowardTip = tangentSpeed > 0f; + if (liveDist <= tricks.LiveCatchBaseDampenDistance) { + if (movingTowardTip && liveDist < tricks.LiveCatchBaseDampenDistance) { + ball.Velocity *= tricks.LiveCatchBaseDampen; + ball.AngularMomentum *= tricks.LiveCatchBaseDampen; } - //Logger.Info("LiveCatchTest - Ball with y-speed {0}, at CollisionTime: {1}, livecatchTime is {2}, difference is {3} msecs", ball.Velocity.y, msec, tricks.FlipperAngleEndTime * 1000, tricks.FlipperAngleEndTime * 1000 - msec); - //Logger.Info("LiveCatchTest - normalspeed = {0}, catchTime = {1}", normalSpeed, catchTime); + return; + } + + // Modern VPW live catch zeroes the flipper-face rebound, but keeps VPE's orientation-independent normal. + ball.Velocity -= math.dot(collEvent.HitNormal, ball.Velocity) * collEvent.HitNormal; + var liveCatchBounceSpeed = tricks.LiveCatchMinimalBounceSpeedMultiplier; + if (catchTime > tricks.LiveCatchPerfectTime && tricks.LiveCatchFullTime > 0f) { + liveCatchBounceSpeed += (catchTime - tricks.LiveCatchPerfectTime) * tricks.LiveCatchInaccurateBounceSpeedMultiplier / tricks.LiveCatchFullTime; } + if (catchTime <= tricks.LiveCatchPerfectTime && movingTowardTip) { + ball.Velocity -= flipperAxis * tangentSpeed; + } + + // VPX applies this as table Y velocity; VPE applies the same speed away from the flipper face. + ball.Velocity += collEvent.HitNormal * liveCatchBounceSpeed; + ball.AngularMomentum = float3.zero; } #endregion - #region Collision public void Collide(ref BallState ball, ref CollisionEventData collEvent, ref FlipperMovementState movementState, @@ -917,6 +930,8 @@ public void Collide(ref BallState ball, ref CollisionEventData collEvent, ref Fl movementState.ApplyImpulse(-jt * crossF, matData.Inertia); } + LiveCatch(ref ball, ref collEvent, in tricks, new float3(_hitCircleBase.Center, ball.Position.z), in matData, timeMsec); + // event if (bnv < -0.25f && timeMsec - movementState.LastHitTime > 250) { // limit rate to 250 milliseconds per event diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs index 5c0d71a96..cc99ea8c5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperColliderComponent.cs @@ -127,39 +127,39 @@ public override bool PhysicsOverwrite { /// If set, apply Flipper Tricks Physics (nFozzy/RothBauerW) /// - [Tooltip("The Rothbauerw's Flipper Tricks Physics")] + [Tooltip("Enables VPW/RothbauerW flipper tricks: start-of-stroke, end-of-stroke, overshoot, and release-bump behavior.")] public bool useFlipperTricksPhysics = false; [Min(0f)] - [Tooltip("Start of stroke RampUp")] + [Tooltip("Coil ramp-up while the flipper starts its stroke. Lower values make the initial flip force arrive faster.")] public float SOSRampUp = 2.5f; [Min(0f)] - [Tooltip("Start of Elasticity multiplier")] + [Tooltip("Rubber elasticity multiplier during the start-of-stroke phase. Values below 1 soften early flip impacts.")] public float SOSEM = 0.85f; [Min(0f)] - [Tooltip("EOSReturnTorque modifier (Torque on depress is original Torque * EOSReturn / Flipper Return Strength)")] + [Tooltip("Return torque multiplier used while the flipper is up and the button is released. Lower values make the flipper drop more softly from EOS.")] public float EOSReturn = 0.055f; [Min(0f)] - [Tooltip("End of stroke Torque")] + [Tooltip("Torque applied once the flipper reaches end-of-stroke. This is the hold strength at EOS.")] public float EOSTNew = 0.8f; [Min(0f)] - [Tooltip("End of stroke Torque Angle")] + [Tooltip("Angle, in degrees from the end position, where EOS hold torque starts applying.")] public float EOSANew = 1.0f; [Min(0f)] - [Tooltip("End of stroke RampUp")] + [Tooltip("Ramp-up used for the EOS hold torque. Zero applies the EOS torque immediately.")] public float EOSRampup = 0.0f; [Min(0f)] - [Tooltip("Degrees of Overshoot above End Angle")] + [Tooltip("How many degrees the flipper may overshoot past its configured end angle before settling back.")] public float Overshoot = 3.0f; [Min(0f)] - [Tooltip("Bump Ball vertically on release button (speed, up)")] + [Tooltip("Upward ball speed added when releasing the flipper button, used to emulate VPW release-bump behavior.")] public float BumpOnRelease = 0.4f; #endregion @@ -168,38 +168,46 @@ public override bool PhysicsOverwrite { /// If set, apply Live Catch (nFozzy/RothBauerW) /// - [Tooltip("The nFozzy's LiveCatch Physics")] + [Tooltip("Enables modern VPW live-catch behavior for balls arriving just after the flipper reaches end-of-stroke.")] public bool useFlipperLiveCatch = false; [Min(0f)] - [Tooltip("Minimum distance in vp units from flipper base live catch dampening will occur")] - public float LiveCatchDistanceMin = 40f; + [Tooltip("Closest distance from the flipper base where live catch can apply, in VP units. Modern VPW scripts usually use 5.")] + public float LiveCatchDistanceMin = 5f; [Min(0f)] - [Tooltip("Maxium distance in vp units from flipper base live catch dampening will occur")] - public float LiveCatchDistanceMax = 100f; + [Tooltip("Farthest distance from the flipper base where live catch can apply, in VP units. Modern VPW scripts usually use 114.")] + public float LiveCatchDistanceMax = 114f; [Min(0f)] - [Tooltip("Minimal ball speed for live catch")] - public float LiveCatchMinimalBallSpeed = 6f; + [Tooltip("Minimum impact speed required for live catch processing. This maps to the modern VPW parm > 3 threshold.")] + public float LiveCatchMinimalBallSpeed = 3f; [Unit("ms")] [Min(0f)] - [Tooltip("Maximum Time in for (perfect or imperfect) live catch")] + [Tooltip("Maximum time, in milliseconds after the flipper reaches EOS, where live catch can still apply.")] public float LiveCatchFullTime = 16; [Unit("ms")] [Min(0f)] - [Tooltip("Maximum Time for a perfect live catch")] + [Tooltip("Time window, in milliseconds after EOS, where the catch is considered perfect and no late-catch bounce is added.")] public float LiveCatchPerfectTime = 8; [Min(0f)] - [Tooltip("Minimum bounce speed multiplier for a live catch (0 allows perfect live catches)")] - public float LiveCatchMinmalBounceSpeedMultiplier = 0.1f; + [Tooltip("Bounce speed used for a perfect live catch. Modern VPW scripts use 0 so perfect catches can fully deaden the rebound.")] + public float LiveCatchMinmalBounceSpeedMultiplier = 0f; [Min(0f)] - [Tooltip("Maximum bounce speed multiplier for an inaccurate live catch")] - public float LiveCatchInaccurateBounceSpeedMultiplier = 1.0f; + [Tooltip("Late-catch bounce speed scale. Modern VPW scripts use 32 and scale it by how late the catch is within the full live-catch window.")] + public float LiveCatchInaccurateBounceSpeedMultiplier = 32f; + + [Min(0f)] + [Tooltip("Distance from the flipper base separating base dampening from normal live catching. Modern VPW scripts use 30 VP units.")] + public float LiveCatchBaseDampenDistance = 30f; + + [Min(0f)] + [Tooltip("Velocity and spin multiplier applied in the base dampen zone. Modern VPW scripts use 0.55.")] + public float LiveCatchBaseDampen = 0.55f; #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs index 8b2280871..68211b263 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs @@ -535,6 +535,8 @@ internal FlipperTricksData GetFlipperTricksData(FlipperColliderComponent collide LiveCatchFullTime = colliderComponent.LiveCatchFullTime, LiveCatchInaccurateBounceSpeedMultiplier = colliderComponent.LiveCatchInaccurateBounceSpeedMultiplier, LiveCatchMinimalBounceSpeedMultiplier = colliderComponent.LiveCatchMinmalBounceSpeedMultiplier, + LiveCatchBaseDampenDistance = colliderComponent.LiveCatchBaseDampenDistance, + LiveCatchBaseDampen = colliderComponent.LiveCatchBaseDampen, //initialize OriginalAngleEnd = staticData.AngleEnd, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs index 69cb1285b..51d444bc5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperTricksData.cs @@ -58,6 +58,8 @@ internal struct FlipperTricksData public float LiveCatchFullTime; public float LiveCatchMinimalBounceSpeedMultiplier; public float LiveCatchInaccurateBounceSpeedMultiplier; + public float LiveCatchBaseDampenDistance; + public float LiveCatchBaseDampen; } } From dbfa9d14f6d43ef395e2fb6bb051046347ee711d Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 18 Apr 2026 23:04:42 +0200 Subject: [PATCH 12/14] plunger: Add "fire and pull back" mode for kickback. --- .../manual/mechanisms/plungers.md | 62 +++++++++++++++++++ .../Documentation~/creators-guide/toc.yml | 10 +-- .../VisualPinball.Unity/Game/CoilPlayer.cs | 19 +++--- .../VPT/Plunger/PlungerApi.cs | 53 +++++++++++----- .../VPT/Plunger/PlungerComponent.cs | 14 +++-- 5 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/plungers.md diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/plungers.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/plungers.md new file mode 100644 index 000000000..6eb2f7fc0 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/plungers.md @@ -0,0 +1,62 @@ +--- +uid: plungers +title: Plungers +description: VPE supports manual plungers, auto plungers, and ROM-controlled kickback-style plungers. +--- + +# Plungers + +Plungers are physics objects that can launch a ball by moving along their stroke. They are commonly used for manual ball launchers, auto-launchers, and kickback mechanisms such as an outlane ball saver. + +## Setup + +Add a *Plunger Collider* to the plunger object to make it part of the physics simulation. The plunger can then be wired through the [Coil Manager](xref:coil_manager) or [Wire Manager](xref:wire_manager), depending on whether it is controlled by game logic or player input. + +The most important plunger settings for gameplay are: + +- *Stroke*: total travel distance of the plunger tip. +- *Park Position*: normalized rest position from `0` to `1`, where `0` is fully forward and `1` is fully retracted. +- *Speed Fire*: release speed used when the plunger fires. +- *Speed Pull*: pull-back speed used when the plunger is pulled back by a coil or input. +- *Mech Plunger*: enables synchronization with analog plunger input. +- *Auto Plunger*: makes the plunger rest at *Park Position* and fire from full retraction when triggered. + +## Coil Modes + +The plunger exposes multiple coil items. Pick the one that matches the real mechanism or the original VPX script behavior. + +| Coil item | Inspector label | Coil enabled | Coil disabled | Typical use | +| --- | --- | --- | --- | --- | +| `c_pull` | Pull back | Pulls the plunger back | Fires from the current position | Manual launch plunger wired to a button or input | +| `c_autofire` | Auto-fire | Fires the plunger | No action | ROM-controlled auto-launchers where the plunger returns to rest naturally | +| `c_fire_pullback` | Fire and pull back | Fires from full retraction | Pulls the plunger back | Kickbacks that call `Fire` on solenoid on and `PullBack` on solenoid off | + +## Manual Launchers + +For a normal shooter lane plunger, map player input to `c_pull`. Pressing the input pulls the plunger back, and releasing it fires from the current position. If the table uses analog plunger input, enable *Mech Plunger* so the physics plunger follows the analog input position. + +## Auto-Launchers + +For ROM-controlled launch buttons, enable *Auto Plunger* and map the game logic coil to `c_autofire`. In this mode the plunger rests at *Park Position*. When the coil fires, VPE launches from full retraction, which gives a consistent launch impulse. + +## Kickbacks + +Some VPX tables implement kickbacks with a plunger object and script the solenoid like this: + +```vb +Sub SolKickback(enabled) + If enabled Then + Plunger1.Fire + Else + Plunger1.PullBack + End If +End Sub +``` + +Use `c_fire_pullback` for this pattern. It fires from full retraction when the ROM enables the coil, and pulls the plunger back when the ROM disables the coil again. + +VPE also applies the disabled state once when this coil mode is mapped at startup. This mirrors VPX tables that call `PullBack` during table initialization, and ensures a kickback starts clear of the ball path before the ROM has emitted its first coil event. + +For kickbacks that should not block the outlane while idle, enable *Auto Plunger* and set *Park Position* to the clear/resting position. This keeps the physics target at the parked position while idle. If the original VPX table uses a small park position such as `0.1666`, start with the same value and tune only if the VPE geometry needs it. + +Avoid enabling *Mech Plunger* on kickbacks unless an analog input should control that specific plunger. A mechanical plunger with no analog input targets position `0`, which is fully forward and can make the plunger block the lane during gameplay. \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index 595be5fe4..62dae2c58 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -99,10 +99,12 @@ items: - name: Troughs / Ball Drains href: manual/mechanisms/troughs.md - - name: Flippers - href: manual/mechanisms/flippers.md - - name: Slingshots - href: manual/mechanisms/slingshots.md + - name: Flippers + href: manual/mechanisms/flippers.md + - name: Plungers + href: manual/mechanisms/plungers.md + - name: Slingshots + href: manual/mechanisms/slingshots.md - name: Light Groups href: manual/mechanisms/light-groups.md - name: Teleporters diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs index 472a2ce6a..92249149f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CoilPlayer.cs @@ -117,13 +117,18 @@ 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); - } - } + 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs index c61d2d73b..99192ba92 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs @@ -52,20 +52,26 @@ public class PlungerApi : CollidableApi - /// Auto-fires the plunger. - /// - public DeviceCoil FireCoil; - - // todo - public event EventHandler Timer; + /// Auto-fires the plunger. + /// + public DeviceCoil FireCoil; + + /// + /// Fires the plunger when enabled, and pulls it back when disabled. + /// + public DeviceCoil FireAndPullBackCoil; + + // todo + public event EventHandler Timer; public bool DoRetract { get; set; } = true; internal PlungerApi(GameObject go, Player player, PhysicsEngine physicsEngine) : base(go, player, physicsEngine) - { - PullCoil = new DeviceCoil(Player, PullBack, Fire); - FireCoil = new DeviceCoil(Player, Fire); - } + { + PullCoil = new DeviceCoil(Player, PullBack, Fire); + FireCoil = new DeviceCoil(Player, Fire); + FireAndPullBackCoil = new DeviceCoil(Player, FireFromFullRetract, PullBack); + } internal void OnAnalogPlunge(InputAction.CallbackContext ctx) { @@ -106,6 +112,16 @@ public void PullBack() } public void Fire() + { + Fire(null); + } + + public void FireFromFullRetract() + { + Fire(1f); + } + + private void Fire(float? startPositionOverride) { var collComponent = GameObject.GetComponent(); if (!collComponent) { @@ -117,7 +133,9 @@ public void Fire() ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); // check for an auto plunger - if (isAutoPlunger) { + if (startPositionOverride.HasValue) { + PlungerCommands.Fire(startPositionOverride.Value, ref plungerState.Velocity, ref plungerState.Movement, in plungerState.Static); + } else if (isAutoPlunger) { // Auto Plunger - this models a "Launch Ball" button or a // ROM-controlled launcher, rather than a player-operated // spring plunger. In a physical machine, this would be @@ -141,12 +159,13 @@ public void Fire() private IApiCoil Coil(string deviceItem) { return deviceItem switch - { - PlungerComponent.FireCoilId => FireCoil, - PlungerComponent.PullCoilId => PullCoil, - _ => throw new ArgumentException($"Unknown plunger coil \"{deviceItem}\". Valid names are: [ \"{PlungerComponent.FireCoilId}\", \"{PlungerComponent.PullCoilId}\" ].") - }; - } + { + PlungerComponent.FireCoilId => FireCoil, + PlungerComponent.PullCoilId => PullCoil, + PlungerComponent.FireAndPullBackCoilId => FireAndPullBackCoil, + _ => throw new ArgumentException($"Unknown plunger coil \"{deviceItem}\". Valid names are: [ \"{PlungerComponent.FireCoilId}\", \"{PlungerComponent.PullCoilId}\", \"{PlungerComponent.FireAndPullBackCoilId}\" ].") + }; + } #region Collider Generation diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs index 33a74736f..1ac64443c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs @@ -73,8 +73,9 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac protected override Type MeshComponentType { get; } = typeof(MeshComponent); protected override Type ColliderComponentType { get; } = typeof(ColliderComponent); - public const string PullCoilId = "c_pull"; - public const string FireCoilId = "c_autofire"; + public const string PullCoilId = "c_pull"; + public const string FireCoilId = "c_autofire"; + public const string FireAndPullBackCoilId = "c_fire_pullback"; #endregion #region Runtime @@ -97,10 +98,11 @@ private void Awake() #region Wiring - public IEnumerable AvailableCoils => new[] { - new GamelogicEngineCoil(PullCoilId) { Description = "Pull back" }, - new GamelogicEngineCoil(FireCoilId) { Description = "Auto-fire" }, - }; + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(PullCoilId) { Description = "Pull back" }, + new GamelogicEngineCoil(FireCoilId) { Description = "Auto-fire" }, + new GamelogicEngineCoil(FireAndPullBackCoilId) { Description = "Fire and pull back" }, + }; IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => ((IApiCoilDevice)PlungerApi).Coil(deviceId); From 9a14087c6b08754fc9dacd65fe0ac5b54cfd3f01 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 19 Apr 2026 01:13:05 +0200 Subject: [PATCH 13/14] fix: Address PR comments. --- .../linux-x64/libVpeNativeInput.so.meta | 12 +-- .../Import/VpxImportWizardSettings.cs | 2 - .../VisualPinball.Unity/Game/SwitchPlayer.cs | 22 +++-- .../VisualPinball.Unity/Game/WirePlayer.cs | 56 ++++++----- .../VisualPinball.Unity/Input/InputManager.cs | 95 ++++++++++++------- .../Simulation/NativeInputApi.cs | 2 +- .../Simulation/NativeInputManager.cs | 3 +- 7 files changed, 109 insertions(+), 83 deletions(-) diff --git a/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta b/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta index 303fd34ca..2d424e64d 100644 --- a/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta +++ b/VisualPinball.Unity/Plugins/linux-x64/libVpeNativeInput.so.meta @@ -16,13 +16,13 @@ PluginImporter: settings: Is16KbAligned: false Any: - enabled: 1 + enabled: 0 settings: Exclude Editor: 0 Exclude Linux64: 0 Exclude OSXUniversal: 1 - Exclude Win: 0 - Exclude Win64: 0 + Exclude Win: 1 + Exclude Win64: 1 Editor: enabled: 1 settings: @@ -38,11 +38,11 @@ PluginImporter: settings: CPU: None Win: - enabled: 1 + enabled: 0 settings: - CPU: x86 + CPU: None Win64: - enabled: 1 + enabled: 0 settings: CPU: None userData: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs index fde182a55..b399da4ba 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxImportWizardSettings.cs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using System; using System.IO; using UnityEditor; using UnityEngine; @@ -22,7 +21,6 @@ namespace VisualPinball.Unity.Editor { - [Serializable] public static class VpxImportWizardSettings { public static bool ApplyPatch diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs index 7f777a347..5e7b4901e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/SwitchPlayer.cs @@ -102,14 +102,15 @@ public void OnStart() break; } - case SwitchSource.InputSystem: - if (!_keySwitchAssignments.ContainsKey(switchMapping.InputAction)) { - _keySwitchAssignments[switchMapping.InputAction] = new List(); - } - var keyboardSwitch = new KeyboardSwitch(switchMapping.Id, switchMapping.IsNormallyClosed); - _keySwitchAssignments[switchMapping.InputAction].Add(keyboardSwitch); - SwitchStatuses[switchMapping.Id] = keyboardSwitch; - break; + case SwitchSource.InputSystem: + var inputAction = InputManager.GetCanonicalActionName(switchMapping.InputAction); + if (!_keySwitchAssignments.ContainsKey(inputAction)) { + _keySwitchAssignments[inputAction] = new List(); + } + var keyboardSwitch = new KeyboardSwitch(switchMapping.Id, switchMapping.IsNormallyClosed); + _keySwitchAssignments[inputAction].Add(keyboardSwitch); + SwitchStatuses[switchMapping.Id] = keyboardSwitch; + break; case SwitchSource.Constant: SwitchStatuses[switchMapping.Id] = new ConstantSwitch(switchMapping.Constant == SwitchConstant.Closed); @@ -138,8 +139,9 @@ private void HandleKeyInput(object obj, InputActionChange change) switch (change) { case InputActionChange.ActionStarted: case InputActionChange.ActionCanceled: - var action = (InputAction)obj; - if (_keySwitchAssignments.TryGetValue(action.name, out var assignment)) { + var action = (InputAction)obj; + var actionName = InputManager.GetCanonicalActionName(action.name); + if (_keySwitchAssignments.TryGetValue(actionName, out var assignment)) { if (_player != null) { foreach (var sw in assignment) { sw.IsSwitchEnabled = change == InputActionChange.ActionStarted; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs index b00ba0ca8..0d1e61e06 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs @@ -133,13 +133,14 @@ internal void AddWire(WireMapping wireMapping, bool isHardwareRule = false) break; } - case SwitchSource.InputSystem: { - if (!_keyWireAssignments.ContainsKey(wireMapping.SourceInputAction)) { - _keyWireAssignments[wireMapping.SourceInputAction] = new List(); - } - _keyWireAssignments[wireMapping.SourceInputAction].Add(SetupWireDestConfig(wireMapping, isHardwareRule)); - break; - } + case SwitchSource.InputSystem: { + var inputAction = InputManager.GetCanonicalActionName(wireMapping.SourceInputAction); + if (!_keyWireAssignments.ContainsKey(inputAction)) { + _keyWireAssignments[inputAction] = new List(); + } + _keyWireAssignments[inputAction].Add(SetupWireDestConfig(wireMapping, isHardwareRule)); + break; + } case SwitchSource.Constant: // todo @@ -186,11 +187,12 @@ private WireDestConfig SetupWireDestConfig(WireMapping wireMapping, bool isHardw private bool GetGamelogicEngineIds(WireMapping wireMapping, out string src, out string dest) { var sourceMapping = _tableComponent.MappingConfig.Switches.FirstOrDefault(switchMapping => { - return switchMapping.Source switch { - SwitchSource.InputSystem => switchMapping.InputActionMap == wireMapping.SourceInputActionMap && switchMapping.InputAction == wireMapping.SourceInputAction, - SwitchSource.Playfield => switchMapping.Device == wireMapping.SourceDevice && switchMapping.DeviceItem == wireMapping.SourceDeviceItem, - _ => false - }; + return switchMapping.Source switch { + SwitchSource.InputSystem => switchMapping.InputActionMap == wireMapping.SourceInputActionMap && + InputManager.GetCanonicalActionName(switchMapping.InputAction) == InputManager.GetCanonicalActionName(wireMapping.SourceInputAction), + SwitchSource.Playfield => switchMapping.Device == wireMapping.SourceDevice && switchMapping.DeviceItem == wireMapping.SourceDeviceItem, + _ => false + }; }); var destMapping = _tableComponent.MappingConfig.Coils.FirstOrDefault(coilMapping => coilMapping.Device == wireMapping.DestinationDevice && @@ -225,17 +227,18 @@ internal void RemoveWire(WireMapping wireMapping) Logger.Warn($"Unknown switch \"{wireMapping.Src}\" to wire to \"{wireMapping.Dst}\"."); } break; - } - - case SwitchSource.InputSystem: { - if (!_keyWireAssignments.ContainsKey(wireMapping.SourceInputAction)) { - _keyWireAssignments[wireMapping.SourceInputAction] = new List(); - } - var assignment = _keyWireAssignments[wireMapping.SourceInputAction] - .FirstOrDefault(a => a.IsHardwareRule && a.Device == wireMapping.DestinationDevice && a.DeviceItem == wireMapping.DestinationDeviceItem); - _keyWireAssignments[wireMapping.SourceInputAction].Remove(assignment); - break; - } + } + + case SwitchSource.InputSystem: { + var inputAction = InputManager.GetCanonicalActionName(wireMapping.SourceInputAction); + if (!_keyWireAssignments.ContainsKey(inputAction)) { + _keyWireAssignments[inputAction] = new List(); + } + var assignment = _keyWireAssignments[inputAction] + .FirstOrDefault(a => a.IsHardwareRule && a.Device == wireMapping.DestinationDevice && a.DeviceItem == wireMapping.DestinationDeviceItem); + _keyWireAssignments[inputAction].Remove(assignment); + break; + } case SwitchSource.Constant: break; @@ -255,9 +258,10 @@ private void HandleKeyInput(object obj, InputActionChange change) switch (change) { case InputActionChange.ActionStarted: case InputActionChange.ActionCanceled: - var action = (InputAction)obj; - if (_keyWireAssignments != null && _keyWireAssignments.ContainsKey(action.name)) { - foreach (var wireConfig in _keyWireAssignments[action.name]) { + var action = (InputAction)obj; + var actionName = InputManager.GetCanonicalActionName(action.name); + if (_keyWireAssignments != null && _keyWireAssignments.ContainsKey(actionName)) { + foreach (var wireConfig in _keyWireAssignments[actionName]) { if (wireConfig.Device == null || !_wireDevices.ContainsKey(wireConfig.Device)) { continue; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs index e91910ed8..45bbc68de 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs @@ -120,32 +120,55 @@ public List GetActionNames(string mapName) return list; } - public InputAction FindAction(string mapName, string actionName) - { - InputAction action = null; - - var actionMap = _asset.FindActionMap(mapName); - - if (actionMap != null) - { - action = actionMap.FindAction(actionName); - } - - return action; - } - - public static InputActionAsset GetDefaultInputActionAsset() - { - var asset = ScriptableObject.CreateInstance(); - var map = new InputActionMap(InputConstants.MapCabinetSwitches); + public InputAction FindAction(string mapName, string actionName) + { + InputAction action = null; + + var actionMap = _asset.FindActionMap(mapName); + + if (actionMap != null) + { + action = actionMap.FindAction(actionName); + if (action != null && action.bindings.Count == 0) { + var canonicalActionName = GetCanonicalActionName(actionName); + if (canonicalActionName != actionName) { + action = actionMap.FindAction(canonicalActionName) ?? action; + } + } + } + + return action; + } + + public static string GetCanonicalActionName(string actionName) + { + return actionName switch { + InputConstants.ActionCoinDoorBack => InputConstants.ActionCoinDoorCancel, + InputConstants.ActionCoinDoorUpDown => InputConstants.ActionCoinDoorCancel, + InputConstants.ActionSelfTest => InputConstants.ActionCoinDoorCancel, + InputConstants.ActionCoinDoorAdvance => InputConstants.ActionCoinDoorDown, + InputConstants.ActionCoinDoorMinus => InputConstants.ActionCoinDoorDown, + InputConstants.ActionCoinDoorPlus => InputConstants.ActionCoinDoorUp, + InputConstants.ActionCoinDoorSelect => InputConstants.ActionCoinDoorEnter, + InputConstants.ActionLeftAdvance => InputConstants.ActionUpperLeftFlipper, + InputConstants.ActionRightAdvance => InputConstants.ActionUpperRightFlipper, + InputConstants.ActionFire1 => InputConstants.ActionLeftMagnasave, + _ => actionName + }; + } + + public static InputActionAsset GetDefaultInputActionAsset() + { + var asset = ScriptableObject.CreateInstance(); + var map = new InputActionMap(InputConstants.MapCabinetSwitches); map.AddAction(InputConstants.ActionUpperLeftFlipper, InputActionType.Button, "/a"); map.AddAction(InputConstants.ActionUpperRightFlipper, InputActionType.Button, "/quote"); map.AddAction(InputConstants.ActionLeftFlipper, InputActionType.Button, "/leftShift").AddBinding("/leftShoulder"); map.AddAction(InputConstants.ActionRightFlipper, InputActionType.Button, "/rightShift").AddBinding("/rightShoulder"); - map.AddAction(InputConstants.ActionRightMagnasave, InputActionType.Button, "/rightCtrl"); - map.AddAction(InputConstants.ActionLeftMagnasave, InputActionType.Button, "/leftCtrl"); - map.AddAction(InputConstants.ActionFire1, InputActionType.Button, "/leftCtrl"); - map.AddAction(InputConstants.ActionFire2, InputActionType.Button, "/rightAlt"); + map.AddAction(InputConstants.ActionRightMagnasave, InputActionType.Button, "/rightCtrl"); + map.AddAction(InputConstants.ActionLeftMagnasave, InputActionType.Button, "/leftCtrl"); + map.AddAction(InputConstants.ActionFire1, InputActionType.Button); + map.AddAction(InputConstants.ActionFire2, InputActionType.Button, "/rightAlt"); map.AddAction(InputConstants.ActionFrontBuyIn, InputActionType.Button, "/2"); map.AddAction(InputConstants.ActionStartGame, InputActionType.Button, "/1"); map.AddAction(InputConstants.ActionPlunger, InputActionType.Button, "/enter"); @@ -155,20 +178,20 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionInsertCoin3, InputActionType.Button, "/3"); map.AddAction(InputConstants.ActionInsertCoin4, InputActionType.Button, "/6"); map.AddAction(InputConstants.ActionCoinDoorOpenClose, InputActionType.Button, "/end"); - map.AddAction(InputConstants.ActionCoinDoorCancel, InputActionType.Button, "/7"); - map.AddAction(InputConstants.ActionCoinDoorDown, InputActionType.Button, "/8"); - map.AddAction(InputConstants.ActionCoinDoorUp, InputActionType.Button, "/9"); - map.AddAction(InputConstants.ActionCoinDoorEnter, InputActionType.Button, "/0"); - map.AddAction(InputConstants.ActionCoinDoorAdvance, InputActionType.Button, "/8"); - map.AddAction(InputConstants.ActionCoinDoorUpDown, InputActionType.Button, "/7"); - map.AddAction(InputConstants.ActionCoinDoorBack, InputActionType.Button, "/7"); - map.AddAction(InputConstants.ActionCoinDoorMinus, InputActionType.Button, "/8"); - map.AddAction(InputConstants.ActionCoinDoorPlus, InputActionType.Button, "/9"); - map.AddAction(InputConstants.ActionCoinDoorSelect, InputActionType.Button, "/0"); - map.AddAction(InputConstants.ActionSlamTilt, InputActionType.Button, "/home"); - map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button, "/7"); - map.AddAction(InputConstants.ActionLeftAdvance, InputActionType.Button, "/a"); - map.AddAction(InputConstants.ActionRightAdvance, InputActionType.Button, "/quote"); + map.AddAction(InputConstants.ActionCoinDoorCancel, InputActionType.Button, "/7"); + map.AddAction(InputConstants.ActionCoinDoorDown, InputActionType.Button, "/8"); + map.AddAction(InputConstants.ActionCoinDoorUp, InputActionType.Button, "/9"); + map.AddAction(InputConstants.ActionCoinDoorEnter, InputActionType.Button, "/0"); + map.AddAction(InputConstants.ActionCoinDoorAdvance, InputActionType.Button); + map.AddAction(InputConstants.ActionCoinDoorUpDown, InputActionType.Button); + map.AddAction(InputConstants.ActionCoinDoorBack, InputActionType.Button); + map.AddAction(InputConstants.ActionCoinDoorMinus, InputActionType.Button); + map.AddAction(InputConstants.ActionCoinDoorPlus, InputActionType.Button); + map.AddAction(InputConstants.ActionCoinDoorSelect, InputActionType.Button); + map.AddAction(InputConstants.ActionSlamTilt, InputActionType.Button, "/home"); + map.AddAction(InputConstants.ActionSelfTest, InputActionType.Button); + map.AddAction(InputConstants.ActionLeftAdvance, InputActionType.Button); + map.AddAction(InputConstants.ActionRightAdvance, InputActionType.Button); asset.AddActionMap(map); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs index 9efdad668..4f774b634 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputApi.cs @@ -130,7 +130,7 @@ public enum KeyCode Minus = 0xBD, // VK_OEM_MINUS Quote = 0xDE, // VK_OEM_7 - Caret = 0xC0, // VK_OEM_3 (layout dependent) + Oem3 = 0xC0, // VK_OEM_3 (layout dependent) } #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs index e7d48e393..f3d7860f4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/NativeInputManager.cs @@ -242,9 +242,8 @@ private void SetupDefaultBindings() AddBinding(NativeInputApi.InputAction.Service2, NativeInputApi.KeyCode.D8); AddBinding(NativeInputApi.InputAction.Service3, NativeInputApi.KeyCode.D9); AddBinding(NativeInputApi.InputAction.Service4, NativeInputApi.KeyCode.D0); - AddBinding(NativeInputApi.InputAction.Service5, NativeInputApi.KeyCode.D6); AddBinding(NativeInputApi.InputAction.Service6, NativeInputApi.KeyCode.PageUp); - AddBinding(NativeInputApi.InputAction.Service7, NativeInputApi.KeyCode.Quote); + AddBinding(NativeInputApi.InputAction.Service7, NativeInputApi.KeyCode.PageDown); // Nudging AddBinding(NativeInputApi.InputAction.LeftNudge, NativeInputApi.KeyCode.Y); From aa6c3963ea6d4286fb36e44ea902de44264e3dc2 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 19 Apr 2026 01:13:18 +0200 Subject: [PATCH 14/14] packaging: Add missing attributes. --- .../VPT/Flipper/FlipperPackable.cs | 117 +++++++++++++----- .../VPT/Kicker/KickerPackable.cs | 6 +- .../VPT/Spinner/SpinnerPackable.cs | 39 +++--- 3 files changed, 113 insertions(+), 49 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperPackable.cs index 0eae03b5b..6cc2d0240 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperPackable.cs @@ -66,41 +66,98 @@ public static void Unpack(byte[] bytes, FlipperComponent comp) } } - public struct FlipperColliderPackable - { - public bool IsMovable; - public float Mass; - public float Strength; - public float Return; - public float RampUp; - public float TorqueDamping; - public float TorqueDampingAngle; - - public static byte[] Pack(FlipperColliderComponent comp) - { - return PackageApi.Packer.Pack(new FlipperColliderPackable { - IsMovable = comp._isKinematic, + public struct FlipperColliderPackable + { + public bool IsMovable; + public float Mass; + public float Strength; + public float Return; + public float RampUp; + public float TorqueDamping; + public float TorqueDampingAngle; + public bool? UseFlipperTricksPhysics; + public float? SOSRampUp; + public float? SOSEM; + public float? EOSReturn; + public float? EOSTNew; + public float? EOSANew; + public float? EOSRampup; + public float? Overshoot; + public float? BumpOnRelease; + public bool? UseFlipperLiveCatch; + public float? LiveCatchDistanceMin; + public float? LiveCatchDistanceMax; + public float? LiveCatchMinimalBallSpeed; + public float? LiveCatchFullTime; + public float? LiveCatchPerfectTime; + public float? LiveCatchMinmalBounceSpeedMultiplier; + public float? LiveCatchInaccurateBounceSpeedMultiplier; + public float? LiveCatchBaseDampenDistance; + public float? LiveCatchBaseDampen; + + public static byte[] Pack(FlipperColliderComponent comp) + { + return PackageApi.Packer.Pack(new FlipperColliderPackable { + IsMovable = comp._isKinematic, Mass = comp.Mass, - Strength = comp.Strength, - Return = comp.Return, - RampUp = comp.RampUp, - TorqueDamping = comp.TorqueDamping, - TorqueDampingAngle = comp.TorqueDampingAngle, - }); - } - - public static void Unpack(byte[] bytes, FlipperColliderComponent comp) + Strength = comp.Strength, + Return = comp.Return, + RampUp = comp.RampUp, + TorqueDamping = comp.TorqueDamping, + TorqueDampingAngle = comp.TorqueDampingAngle, + UseFlipperTricksPhysics = comp.useFlipperTricksPhysics, + SOSRampUp = comp.SOSRampUp, + SOSEM = comp.SOSEM, + EOSReturn = comp.EOSReturn, + EOSTNew = comp.EOSTNew, + EOSANew = comp.EOSANew, + EOSRampup = comp.EOSRampup, + Overshoot = comp.Overshoot, + BumpOnRelease = comp.BumpOnRelease, + UseFlipperLiveCatch = comp.useFlipperLiveCatch, + LiveCatchDistanceMin = comp.LiveCatchDistanceMin, + LiveCatchDistanceMax = comp.LiveCatchDistanceMax, + LiveCatchMinimalBallSpeed = comp.LiveCatchMinimalBallSpeed, + LiveCatchFullTime = comp.LiveCatchFullTime, + LiveCatchPerfectTime = comp.LiveCatchPerfectTime, + LiveCatchMinmalBounceSpeedMultiplier = comp.LiveCatchMinmalBounceSpeedMultiplier, + LiveCatchInaccurateBounceSpeedMultiplier = comp.LiveCatchInaccurateBounceSpeedMultiplier, + LiveCatchBaseDampenDistance = comp.LiveCatchBaseDampenDistance, + LiveCatchBaseDampen = comp.LiveCatchBaseDampen, + }); + } + + public static void Unpack(byte[] bytes, FlipperColliderComponent comp) { var data = PackageApi.Packer.Unpack(bytes); comp._isKinematic = data.IsMovable; comp.Mass = data.Mass; - comp.Strength = data.Strength; - comp.Return = data.Return; - comp.RampUp = data.RampUp; - comp.TorqueDamping = data.TorqueDamping; - comp.TorqueDampingAngle = data.TorqueDampingAngle; - } - } + comp.Strength = data.Strength; + comp.Return = data.Return; + comp.RampUp = data.RampUp; + comp.TorqueDamping = data.TorqueDamping; + comp.TorqueDampingAngle = data.TorqueDampingAngle; + comp.useFlipperTricksPhysics = data.UseFlipperTricksPhysics ?? comp.useFlipperTricksPhysics; + comp.SOSRampUp = data.SOSRampUp ?? comp.SOSRampUp; + comp.SOSEM = data.SOSEM ?? comp.SOSEM; + comp.EOSReturn = data.EOSReturn ?? comp.EOSReturn; + comp.EOSTNew = data.EOSTNew ?? comp.EOSTNew; + comp.EOSANew = data.EOSANew ?? comp.EOSANew; + comp.EOSRampup = data.EOSRampup ?? comp.EOSRampup; + comp.Overshoot = data.Overshoot ?? comp.Overshoot; + comp.BumpOnRelease = data.BumpOnRelease ?? comp.BumpOnRelease; + comp.useFlipperLiveCatch = data.UseFlipperLiveCatch ?? comp.useFlipperLiveCatch; + comp.LiveCatchDistanceMin = data.LiveCatchDistanceMin ?? comp.LiveCatchDistanceMin; + comp.LiveCatchDistanceMax = data.LiveCatchDistanceMax ?? comp.LiveCatchDistanceMax; + comp.LiveCatchMinimalBallSpeed = data.LiveCatchMinimalBallSpeed ?? comp.LiveCatchMinimalBallSpeed; + comp.LiveCatchFullTime = data.LiveCatchFullTime ?? comp.LiveCatchFullTime; + comp.LiveCatchPerfectTime = data.LiveCatchPerfectTime ?? comp.LiveCatchPerfectTime; + comp.LiveCatchMinmalBounceSpeedMultiplier = data.LiveCatchMinmalBounceSpeedMultiplier ?? comp.LiveCatchMinmalBounceSpeedMultiplier; + comp.LiveCatchInaccurateBounceSpeedMultiplier = data.LiveCatchInaccurateBounceSpeedMultiplier ?? comp.LiveCatchInaccurateBounceSpeedMultiplier; + comp.LiveCatchBaseDampenDistance = data.LiveCatchBaseDampenDistance ?? comp.LiveCatchBaseDampenDistance; + comp.LiveCatchBaseDampen = data.LiveCatchBaseDampen ?? comp.LiveCatchBaseDampen; + } + } public struct FlipperColliderReferencesPackable { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerPackable.cs index d3dcdfb93..c8faec86e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerPackable.cs @@ -23,11 +23,14 @@ namespace VisualPinball.Unity { public struct KickerPackable { + public float? Orientation; public IEnumerable Coils; public static byte[] Pack(KickerComponent comp) { - return PackageApi.Packer.Pack(new KickerPackable { Coils = comp.Coils.Select(c => new KickerCoilPackable { + return PackageApi.Packer.Pack(new KickerPackable { + Orientation = comp.Orientation, + Coils = comp.Coils.Select(c => new KickerCoilPackable { Name = c.Name, Id = c.Id, Speed = c.Speed, @@ -40,6 +43,7 @@ public static byte[] Pack(KickerComponent comp) public static void Unpack(byte[] bytes, KickerComponent comp) { var data = PackageApi.Packer.Unpack(bytes); + comp.Orientation = data.Orientation ?? comp.Orientation; comp.Coils = data.Coils.Select(c => new KickerCoil { Name = c.Name, Id = c.Id, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPackable.cs index 654fcd0d7..f80571460 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPackable.cs @@ -42,24 +42,27 @@ public static void Unpack(byte[] bytes, SpinnerComponent comp) } } - public struct SpinnerColliderPackable - { - public bool IsMovable; - public float ZPosition; - - public static byte[] Pack(SpinnerColliderComponent comp) - { - return PackageApi.Packer.Pack(new SpinnerColliderPackable { - IsMovable = comp._isKinematic, - ZPosition = comp.ZPosition, - }); - } + public struct SpinnerColliderPackable + { + public bool IsMovable; + public float? Mass; + public float ZPosition; + + public static byte[] Pack(SpinnerColliderComponent comp) + { + return PackageApi.Packer.Pack(new SpinnerColliderPackable { + IsMovable = comp._isKinematic, + Mass = comp.Mass, + ZPosition = comp.ZPosition, + }); + } public static void Unpack(byte[] bytes, SpinnerColliderComponent comp) { - var data = PackageApi.Packer.Unpack(bytes); - comp._isKinematic = data.IsMovable; - comp.ZPosition = data.ZPosition; - } - } -} + var data = PackageApi.Packer.Unpack(bytes); + comp._isKinematic = data.IsMovable; + comp.Mass = data.Mass ?? comp.Mass; + comp.ZPosition = data.ZPosition; + } + } +}